touchOutSideViewRef = new WeakReference<>(null);
+
+ public static void setTouchOutSideView(View touchOutSideView) {
+ touchOutSideViewRef = new WeakReference<>(touchOutSideView);
+ }
+
+ public static void replaceComponents(@Nullable Enum> flyoutPanelEnum, @NonNull TextView textView, @NonNull ImageView imageView) {
+ if (flyoutPanelEnum == null)
+ return;
+
+ final String enumString = flyoutPanelEnum.name();
+ final boolean isDismissQue = enumString.equals("DISMISS_QUEUE");
+ final boolean isReport = enumString.equals("FLAG");
+
+ if (isDismissQue) {
+ replaceDismissQueue(textView, imageView);
+ } else if (isReport) {
+ replaceReport(textView, imageView, lastMenuWasDismissQueue);
+ }
+ lastMenuWasDismissQueue = isDismissQue;
+ }
+
+ private static void replaceDismissQueue(@NonNull TextView textView, @NonNull ImageView imageView) {
+ if (!Settings.REPLACE_FLYOUT_MENU_DISMISS_QUEUE.get())
+ return;
+
+ if (!(textView.getParent() instanceof ViewGroup clickAbleArea))
+ return;
+
+ runOnMainThreadDelayed(() -> {
+ textView.setText(str("revanced_replace_flyout_menu_dismiss_queue_watch_on_youtube_label"));
+ imageView.setImageResource(getIdentifier("yt_outline_youtube_logo_icon_vd_theme_24", ResourceType.DRAWABLE, clickAbleArea.getContext()));
+ clickAbleArea.setOnClickListener(viewGroup -> VideoUtils.openInYouTube());
+ }, 0L
+ );
+ }
+
+ private static final ColorFilter cf = new PorterDuffColorFilter(Color.parseColor("#ffffffff"), PorterDuff.Mode.SRC_ATOP);
+
+ private static void replaceReport(@NonNull TextView textView, @NonNull ImageView imageView, boolean wasDismissQueue) {
+ if (!Settings.REPLACE_FLYOUT_MENU_REPORT.get())
+ return;
+
+ if (Settings.REPLACE_FLYOUT_MENU_REPORT_ONLY_PLAYER.get() && !wasDismissQueue)
+ return;
+
+ if (!(textView.getParent() instanceof ViewGroup clickAbleArea))
+ return;
+
+ runOnMainThreadDelayed(() -> {
+ textView.setText(str("playback_rate_title"));
+ imageView.setImageResource(getIdentifier("yt_outline_play_arrow_half_circle_black_24", ResourceType.DRAWABLE, clickAbleArea.getContext()));
+ imageView.setColorFilter(cf);
+ clickAbleArea.setOnClickListener(view -> {
+ clickView(touchOutSideViewRef.get());
+ VideoUtils.showPlaybackSpeedFlyoutMenu();
+ });
+ }, 0L
+ );
+ }
+
+ private enum FlyoutPanelComponent {
+ SAVE_EPISODE_FOR_LATER("BOOKMARK_BORDER", Settings.HIDE_FLYOUT_MENU_SAVE_EPISODE_FOR_LATER.get()),
+ SHUFFLE_PLAY("SHUFFLE", Settings.HIDE_FLYOUT_MENU_SHUFFLE_PLAY.get()),
+ RADIO("MIX", Settings.HIDE_FLYOUT_MENU_START_RADIO.get()),
+ SUBSCRIBE("SUBSCRIBE", Settings.HIDE_FLYOUT_MENU_SUBSCRIBE.get()),
+ EDIT_PLAYLIST("EDIT", Settings.HIDE_FLYOUT_MENU_EDIT_PLAYLIST.get()),
+ DELETE_PLAYLIST("DELETE", Settings.HIDE_FLYOUT_MENU_DELETE_PLAYLIST.get()),
+ PLAY_NEXT("QUEUE_PLAY_NEXT", Settings.HIDE_FLYOUT_MENU_PLAY_NEXT.get()),
+ ADD_TO_QUEUE("QUEUE_MUSIC", Settings.HIDE_FLYOUT_MENU_ADD_TO_QUEUE.get()),
+ SAVE_TO_LIBRARY("LIBRARY_ADD", Settings.HIDE_FLYOUT_MENU_SAVE_TO_LIBRARY.get()),
+ REMOVE_FROM_LIBRARY("LIBRARY_REMOVE", Settings.HIDE_FLYOUT_MENU_REMOVE_FROM_LIBRARY.get()),
+ REMOVE_FROM_PLAYLIST("REMOVE_FROM_PLAYLIST", Settings.HIDE_FLYOUT_MENU_REMOVE_FROM_PLAYLIST.get()),
+ DOWNLOAD("OFFLINE_DOWNLOAD", Settings.HIDE_FLYOUT_MENU_DOWNLOAD.get()),
+ SAVE_TO_PLAYLIST("ADD_TO_PLAYLIST", Settings.HIDE_FLYOUT_MENU_SAVE_TO_PLAYLIST.get()),
+ GO_TO_EPISODE("INFO", Settings.HIDE_FLYOUT_MENU_GO_TO_EPISODE.get()),
+ GO_TO_PODCAST("BROADCAST", Settings.HIDE_FLYOUT_MENU_GO_TO_PODCAST.get()),
+ GO_TO_ALBUM("ALBUM", Settings.HIDE_FLYOUT_MENU_GO_TO_ALBUM.get()),
+ GO_TO_ARTIST("ARTIST", Settings.HIDE_FLYOUT_MENU_GO_TO_ARTIST.get()),
+ VIEW_SONG_CREDIT("PEOPLE_GROUP", Settings.HIDE_FLYOUT_MENU_VIEW_SONG_CREDIT.get()),
+ SHARE("SHARE", Settings.HIDE_FLYOUT_MENU_SHARE.get()),
+ DISMISS_QUEUE("DISMISS_QUEUE", Settings.HIDE_FLYOUT_MENU_DISMISS_QUEUE.get()),
+ HELP("HELP_OUTLINE", Settings.HIDE_FLYOUT_MENU_HELP.get()),
+ REPORT("FLAG", Settings.HIDE_FLYOUT_MENU_REPORT.get()),
+ QUALITY("SETTINGS_MATERIAL", Settings.HIDE_FLYOUT_MENU_QUALITY.get()),
+ CAPTIONS("CAPTIONS", Settings.HIDE_FLYOUT_MENU_CAPTIONS.get()),
+ STATS_FOR_NERDS("PLANNER_REVIEW", Settings.HIDE_FLYOUT_MENU_STATS_FOR_NERDS.get()),
+ SLEEP_TIMER("MOON_Z", Settings.HIDE_FLYOUT_MENU_SLEEP_TIMER.get());
+
+ private final boolean enabled;
+ private final String name;
+
+ FlyoutPanelComponent(String name, boolean enabled) {
+ this.enabled = enabled;
+ this.name = name;
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/general/GeneralPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/general/GeneralPatch.java
new file mode 100644
index 0000000000..72d3ba3f30
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/general/GeneralPatch.java
@@ -0,0 +1,181 @@
+package app.revanced.extension.music.patches.general;
+
+import static app.revanced.extension.music.utils.ExtendedUtils.isSpoofingToLessThan;
+import static app.revanced.extension.shared.utils.Utils.hideViewBy0dpUnderCondition;
+
+import android.app.AlertDialog;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.Button;
+import android.widget.ImageView;
+
+import app.revanced.extension.music.settings.Settings;
+
+/**
+ * @noinspection ALL
+ */
+@SuppressWarnings("unused")
+public class GeneralPatch {
+
+ // region [Change start page] patch
+
+ public static String changeStartPage(final String browseId) {
+ if (!browseId.equals("FEmusic_home"))
+ return browseId;
+
+ return Settings.CHANGE_START_PAGE.get();
+ }
+
+ // endregion
+
+ // region [Disable dislike redirection] patch
+
+ public static boolean disableDislikeRedirection() {
+ return Settings.DISABLE_DISLIKE_REDIRECTION.get();
+ }
+
+ // endregion
+
+ // region [Enable landscape mode] patch
+
+ public static boolean enableLandScapeMode(boolean original) {
+ return Settings.ENABLE_LANDSCAPE_MODE.get() || original;
+ }
+
+ // endregion
+
+ // region [Hide layout components] patch
+
+ public static int hideCastButton(int original) {
+ return Settings.HIDE_CAST_BUTTON.get() ? View.GONE : original;
+ }
+
+ public static void hideCastButton(View view) {
+ hideViewBy0dpUnderCondition(Settings.HIDE_CAST_BUTTON.get(), view);
+ }
+
+ public static void hideCategoryBar(View view) {
+ hideViewBy0dpUnderCondition(Settings.HIDE_CATEGORY_BAR.get(), view);
+ }
+
+ public static boolean hideFloatingButton() {
+ return Settings.HIDE_FLOATING_BUTTON.get();
+ }
+
+ public static boolean hideTapToUpdateButton() {
+ return Settings.HIDE_TAP_TO_UPDATE_BUTTON.get();
+ }
+
+ public static boolean hideHistoryButton(boolean original) {
+ return !Settings.HIDE_HISTORY_BUTTON.get() && original;
+ }
+
+ public static void hideNotificationButton(View view) {
+ if (view.getParent() instanceof ViewGroup viewGroup) {
+ hideViewBy0dpUnderCondition(Settings.HIDE_NOTIFICATION_BUTTON, viewGroup);
+ }
+ }
+
+ public static boolean hideSoundSearchButton(boolean original) {
+ if (!Settings.SETTINGS_INITIALIZED.get()) {
+ return original;
+ }
+ return !Settings.HIDE_SOUND_SEARCH_BUTTON.get();
+ }
+
+ public static void hideVoiceSearchButton(ImageView view, int visibility) {
+ final int finalVisibility = Settings.HIDE_VOICE_SEARCH_BUTTON.get()
+ ? View.GONE
+ : visibility;
+
+ view.setVisibility(finalVisibility);
+ }
+
+ public static void hideTasteBuilder(View view) {
+ view.setVisibility(View.GONE);
+ }
+
+
+ // endregion
+
+ // region [Hide overlay filter] patch
+
+ public static void disableDimBehind(Window window) {
+ if (window != null) {
+ // Disable AlertDialog's background dim.
+ window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
+ }
+ }
+
+ // endregion
+
+ // region [Remove viewer discretion dialog] patch
+
+ /**
+ * Injection point.
+ *
+ * The {@link AlertDialog#getButton(int)} method must be used after {@link AlertDialog#show()} is called.
+ * Otherwise {@link AlertDialog#getButton(int)} method will always return null.
+ * https://stackoverflow.com/a/4604145
+ *
+ * That's why {@link AlertDialog#show()} is absolutely necessary.
+ * Instead, use two tricks to hide Alertdialog.
+ *
+ * 1. Change the size of AlertDialog to 0.
+ * 2. Disable AlertDialog's background dim.
+ *
+ * This way, AlertDialog will be completely hidden,
+ * and {@link AlertDialog#getButton(int)} method can be used without issue.
+ */
+ public static void confirmDialog(final AlertDialog dialog) {
+ if (!Settings.REMOVE_VIEWER_DISCRETION_DIALOG.get()) {
+ return;
+ }
+
+ // This method is called after AlertDialog#show(),
+ // So we need to hide the AlertDialog before pressing the possitive button.
+ final Window window = dialog.getWindow();
+ final Button button = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
+ if (window != null && button != null) {
+ WindowManager.LayoutParams params = window.getAttributes();
+ params.height = 0;
+ params.width = 0;
+
+ // Change the size of AlertDialog to 0.
+ window.setAttributes(params);
+
+ // Disable AlertDialog's background dim.
+ window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
+
+ button.callOnClick();
+ }
+ }
+
+ // endregion
+
+ // region [Restore old style library shelf] patch
+
+ public static String restoreOldStyleLibraryShelf(final String browseId) {
+ final boolean oldStyleLibraryShelfEnabled =
+ Settings.RESTORE_OLD_STYLE_LIBRARY_SHELF.get() || isSpoofingToLessThan("5.38.00");
+ return oldStyleLibraryShelfEnabled && browseId.equals("FEmusic_library_landing")
+ ? "FEmusic_liked"
+ : browseId;
+ }
+
+ // endregion
+
+ // region [Spoof app version] patch
+
+ public static String getVersionOverride(String version) {
+ if (!Settings.SPOOF_APP_VERSION.get())
+ return version;
+
+ return Settings.SPOOF_APP_VERSION_TARGET.get();
+ }
+
+ // endregion
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/general/SettingsMenuPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/general/SettingsMenuPatch.java
new file mode 100644
index 0000000000..27359dcc9c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/general/SettingsMenuPatch.java
@@ -0,0 +1,41 @@
+package app.revanced.extension.music.patches.general;
+
+import androidx.preference.PreferenceScreen;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.shared.patches.BaseSettingsMenuPatch;
+
+@SuppressWarnings("unused")
+public final class SettingsMenuPatch extends BaseSettingsMenuPatch {
+
+ public static void hideSettingsMenu(PreferenceScreen mPreferenceScreen) {
+ if (mPreferenceScreen == null) return;
+ for (SettingsMenuComponent component : SettingsMenuComponent.values())
+ if (component.enabled)
+ removePreference(mPreferenceScreen, component.key);
+ }
+
+ public static boolean hideParentToolsMenu(boolean original) {
+ return !Settings.HIDE_SETTINGS_MENU_PARENT_TOOLS.get() && original;
+ }
+
+ private enum SettingsMenuComponent {
+ GENERAL("settings_header_general", Settings.HIDE_SETTINGS_MENU_GENERAL.get()),
+ PLAYBACK("settings_header_playback", Settings.HIDE_SETTINGS_MENU_PLAYBACK.get()),
+ DATA_SAVING("settings_header_data_saving", Settings.HIDE_SETTINGS_MENU_DATA_SAVING.get()),
+ DOWNLOADS_AND_STORAGE("settings_header_downloads_and_storage", Settings.HIDE_SETTINGS_MENU_DOWNLOADS_AND_STORAGE.get()),
+ NOTIFICATIONS("settings_header_notifications", Settings.HIDE_SETTINGS_MENU_NOTIFICATIONS.get()),
+ PRIVACY_AND_LOCATION("settings_header_privacy_and_location", Settings.HIDE_SETTINGS_MENU_PRIVACY_AND_LOCATION.get()),
+ RECOMMENDATIONS("settings_header_recommendations", Settings.HIDE_SETTINGS_MENU_RECOMMENDATIONS.get()),
+ PAID_MEMBERSHIPS("settings_header_paid_memberships", Settings.HIDE_SETTINGS_MENU_PAID_MEMBERSHIPS.get()),
+ ABOUT("settings_header_about_youtube_music", Settings.HIDE_SETTINGS_MENU_ABOUT.get());
+
+ private final String key;
+ private final boolean enabled;
+
+ SettingsMenuComponent(String key, boolean enabled) {
+ this.key = key;
+ this.enabled = enabled;
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/CairoSplashAnimationPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/CairoSplashAnimationPatch.java
new file mode 100644
index 0000000000..c1c9c5094d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/CairoSplashAnimationPatch.java
@@ -0,0 +1,11 @@
+package app.revanced.extension.music.patches.misc;
+
+import app.revanced.extension.music.settings.Settings;
+
+@SuppressWarnings("unused")
+public class CairoSplashAnimationPatch {
+
+ public static boolean disableCairoSplashAnimation(boolean original) {
+ return !Settings.DISABLE_CAIRO_SPLASH_ANIMATION.get() && original;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/OpusCodecPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/OpusCodecPatch.java
new file mode 100644
index 0000000000..5dec961fad
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/OpusCodecPatch.java
@@ -0,0 +1,11 @@
+package app.revanced.extension.music.patches.misc;
+
+import app.revanced.extension.music.settings.Settings;
+
+@SuppressWarnings("unused")
+public class OpusCodecPatch {
+
+ public static boolean enableOpusCodec() {
+ return Settings.ENABLE_OPUS_CODEC.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/ShareSheetPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/ShareSheetPatch.java
new file mode 100644
index 0000000000..afcaaa77e8
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/misc/ShareSheetPatch.java
@@ -0,0 +1,41 @@
+package app.revanced.extension.music.patches.misc;
+
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.view.ViewGroup;
+
+import app.revanced.extension.music.patches.components.ShareSheetMenuFilter;
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+@SuppressWarnings("unused")
+public class ShareSheetPatch {
+ /**
+ * Injection point.
+ */
+ public static void onShareSheetMenuCreate(final RecyclerView recyclerView) {
+ if (!Settings.CHANGE_SHARE_SHEET.get())
+ return;
+
+ recyclerView.getViewTreeObserver().addOnDrawListener(() -> {
+ try {
+ if (!ShareSheetMenuFilter.isShareSheetMenuVisible)
+ return;
+ if (!(recyclerView.getChildAt(0) instanceof ViewGroup shareContainer)) {
+ return;
+ }
+ if (!(shareContainer.getChildAt(shareContainer.getChildCount() - 1) instanceof ViewGroup shareWithOtherAppsView)) {
+ return;
+ }
+ ShareSheetMenuFilter.isShareSheetMenuVisible = false;
+
+ recyclerView.setVisibility(View.GONE);
+ Utils.clickView(shareWithOtherAppsView);
+ } catch (Exception ex) {
+ Logger.printException(() -> "onShareSheetMenuCreate failure", ex);
+ }
+ });
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/navigation/NavigationPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/navigation/NavigationPatch.java
new file mode 100644
index 0000000000..bf99b8fe6c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/navigation/NavigationPatch.java
@@ -0,0 +1,56 @@
+package app.revanced.extension.music.patches.navigation;
+
+import static app.revanced.extension.shared.utils.Utils.hideViewUnderCondition;
+
+import android.graphics.Color;
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.shared.utils.ResourceUtils;
+
+@SuppressWarnings("unused")
+public class NavigationPatch {
+ private static final int colorGrey12 =
+ ResourceUtils.getColor("revanced_color_grey_12");
+ public static Enum> lastPivotTab;
+
+ public static int enableBlackNavigationBar() {
+ return Settings.ENABLE_BLACK_NAVIGATION_BAR.get()
+ ? Color.BLACK
+ : colorGrey12;
+ }
+
+ public static void hideNavigationLabel(TextView textview) {
+ hideViewUnderCondition(Settings.HIDE_NAVIGATION_LABEL.get(), textview);
+ }
+
+ public static void hideNavigationButton(@NonNull View view) {
+ if (Settings.HIDE_NAVIGATION_BAR.get() && view.getParent() != null) {
+ hideViewUnderCondition(true, (View) view.getParent());
+ return;
+ }
+
+ for (NavigationButton button : NavigationButton.values())
+ if (lastPivotTab.name().equals(button.name))
+ hideViewUnderCondition(button.enabled, view);
+ }
+
+ private enum NavigationButton {
+ HOME("TAB_HOME", Settings.HIDE_NAVIGATION_HOME_BUTTON.get()),
+ SAMPLES("TAB_SAMPLES", Settings.HIDE_NAVIGATION_SAMPLES_BUTTON.get()),
+ EXPLORE("TAB_EXPLORE", Settings.HIDE_NAVIGATION_EXPLORE_BUTTON.get()),
+ LIBRARY("LIBRARY_MUSIC", Settings.HIDE_NAVIGATION_LIBRARY_BUTTON.get()),
+ UPGRADE("TAB_MUSIC_PREMIUM", Settings.HIDE_NAVIGATION_UPGRADE_BUTTON.get());
+
+ private final boolean enabled;
+ private final String name;
+
+ NavigationButton(String name, boolean enabled) {
+ this.enabled = enabled;
+ this.name = name;
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/player/PlayerPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/player/PlayerPatch.java
new file mode 100644
index 0000000000..0e31a45df3
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/player/PlayerPatch.java
@@ -0,0 +1,198 @@
+package app.revanced.extension.music.patches.player;
+
+import static app.revanced.extension.shared.utils.Utils.hideViewByRemovingFromParentUnderCondition;
+import static app.revanced.extension.shared.utils.Utils.hideViewUnderCondition;
+import static app.revanced.extension.shared.utils.Utils.isSDKAbove;
+
+import android.annotation.SuppressLint;
+import android.graphics.Color;
+import android.view.View;
+
+import java.util.Arrays;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.music.shared.VideoType;
+import app.revanced.extension.music.utils.VideoUtils;
+
+@SuppressWarnings({"unused"})
+public class PlayerPatch {
+ private static final int MUSIC_VIDEO_GREY_BACKGROUND_COLOR = -12566464;
+ private static final int MUSIC_VIDEO_ORIGINAL_BACKGROUND_COLOR = -16579837;
+
+ @SuppressLint("StaticFieldLeak")
+ public static View previousButton;
+ @SuppressLint("StaticFieldLeak")
+ public static View nextButton;
+
+ public static boolean disableMiniPlayerGesture() {
+ return Settings.DISABLE_MINI_PLAYER_GESTURE.get();
+ }
+
+ public static boolean disablePlayerGesture() {
+ return Settings.DISABLE_PLAYER_GESTURE.get();
+ }
+
+ public static boolean enableColorMatchPlayer() {
+ return Settings.ENABLE_COLOR_MATCH_PLAYER.get();
+ }
+
+ public static int enableBlackPlayerBackground(int originalColor) {
+ return Settings.ENABLE_BLACK_PLAYER_BACKGROUND.get()
+ && originalColor != MUSIC_VIDEO_GREY_BACKGROUND_COLOR
+ ? Color.BLACK
+ : originalColor;
+ }
+
+ public static boolean enableForceMinimizedPlayer(boolean original) {
+ return Settings.ENABLE_FORCE_MINIMIZED_PLAYER.get() || original;
+ }
+
+ public static boolean enableMiniPlayerNextButton(boolean original) {
+ return !Settings.ENABLE_MINI_PLAYER_NEXT_BUTTON.get() && original;
+ }
+
+ public static View[] getViewArray(View[] oldViewArray) {
+ if (previousButton != null) {
+ if (nextButton != null) {
+ return getViewArray(getViewArray(oldViewArray, previousButton), nextButton);
+ } else {
+ return getViewArray(oldViewArray, previousButton);
+ }
+ } else {
+ return oldViewArray;
+ }
+ }
+
+ private static View[] getViewArray(View[] oldViewArray, View newView) {
+ final int oldViewArrayLength = oldViewArray.length;
+
+ View[] newViewArray = Arrays.copyOf(oldViewArray, oldViewArrayLength + 1);
+ newViewArray[oldViewArrayLength] = newView;
+ return newViewArray;
+ }
+
+ public static void setNextButton(View nextButtonView) {
+ if (nextButtonView == null)
+ return;
+
+ hideViewUnderCondition(
+ !Settings.ENABLE_MINI_PLAYER_NEXT_BUTTON.get(),
+ nextButtonView
+ );
+
+ nextButtonView.setOnClickListener(PlayerPatch::setNextButtonOnClickListener);
+ }
+
+ // rest of the implementation added by patch.
+ private static void setNextButtonOnClickListener(View view) {
+ if (Settings.ENABLE_MINI_PLAYER_NEXT_BUTTON.get())
+ view.getClass();
+ }
+
+ public static void setPreviousButton(View previousButtonView) {
+ if (previousButtonView == null)
+ return;
+
+ hideViewUnderCondition(
+ !Settings.ENABLE_MINI_PLAYER_PREVIOUS_BUTTON.get(),
+ previousButtonView
+ );
+
+ previousButtonView.setOnClickListener(PlayerPatch::setPreviousButtonOnClickListener);
+ }
+
+ // rest of the implementation added by patch.
+ private static void setPreviousButtonOnClickListener(View view) {
+ if (Settings.ENABLE_MINI_PLAYER_PREVIOUS_BUTTON.get())
+ view.getClass();
+ }
+
+ public static boolean enableSwipeToDismissMiniPlayer() {
+ return Settings.ENABLE_SWIPE_TO_DISMISS_MINI_PLAYER.get();
+ }
+
+ public static boolean enableSwipeToDismissMiniPlayer(boolean original) {
+ return !Settings.ENABLE_SWIPE_TO_DISMISS_MINI_PLAYER.get() && original;
+ }
+
+ public static Object enableSwipeToDismissMiniPlayer(Object object) {
+ return Settings.ENABLE_SWIPE_TO_DISMISS_MINI_PLAYER.get() ? null : object;
+ }
+
+ public static int enableZenMode(int originalColor) {
+ if (Settings.ENABLE_ZEN_MODE.get() && originalColor == MUSIC_VIDEO_ORIGINAL_BACKGROUND_COLOR) {
+ if (Settings.ENABLE_ZEN_MODE_PODCAST.get() || !VideoType.getCurrent().isPodCast()) {
+ return MUSIC_VIDEO_GREY_BACKGROUND_COLOR;
+ }
+ }
+ return originalColor;
+ }
+
+ public static void hideAudioVideoSwitchToggle(View view, int originalVisibility) {
+ if (Settings.HIDE_AUDIO_VIDEO_SWITCH_TOGGLE.get()) {
+ originalVisibility = View.GONE;
+ }
+ view.setVisibility(originalVisibility);
+ }
+
+ public static void hideDoubleTapOverlayFilter(View view) {
+ hideViewByRemovingFromParentUnderCondition(Settings.HIDE_DOUBLE_TAP_OVERLAY_FILTER, view);
+ }
+
+ public static int hideFullscreenShareButton(int original) {
+ return Settings.HIDE_FULLSCREEN_SHARE_BUTTON.get() ? 0 : original;
+ }
+
+ public static void setShuffleState(Enum> shuffleState) {
+ if (!Settings.REMEMBER_SHUFFLE_SATE.get())
+ return;
+ Settings.ALWAYS_SHUFFLE.save(shuffleState.ordinal() == 1);
+ }
+
+ public static void shuffleTracks() {
+ if (!Settings.ALWAYS_SHUFFLE.get())
+ return;
+ VideoUtils.shuffleTracks();
+ }
+
+ public static boolean rememberRepeatState(boolean original) {
+ return Settings.REMEMBER_REPEAT_SATE.get() || original;
+ }
+
+ public static boolean rememberShuffleState() {
+ return Settings.REMEMBER_SHUFFLE_SATE.get();
+ }
+
+ public static boolean restoreOldCommentsPopUpPanels() {
+ return restoreOldCommentsPopUpPanels(true);
+ }
+
+ public static boolean restoreOldCommentsPopUpPanels(boolean original) {
+ if (!Settings.SETTINGS_INITIALIZED.get()) {
+ return original;
+ }
+ return !Settings.RESTORE_OLD_COMMENTS_POPUP_PANELS.get() && original;
+ }
+
+ public static boolean restoreOldPlayerBackground(boolean original) {
+ if (!Settings.SETTINGS_INITIALIZED.get()) {
+ return original;
+ }
+ if (!isSDKAbove(23)) {
+ // Disable this patch on Android 5.0 / 5.1 to fix a black play button.
+ // Android 5.x have a different design for play button,
+ // and if the new background is applied forcibly, the play button turns black.
+ // 6.20.51 uses the old background from the beginning, so there is no impact.
+ return original;
+ }
+ return !Settings.RESTORE_OLD_PLAYER_BACKGROUND.get();
+ }
+
+ public static boolean restoreOldPlayerLayout(boolean original) {
+ if (!Settings.SETTINGS_INITIALIZED.get()) {
+ return original;
+ }
+ return !Settings.RESTORE_OLD_PLAYER_LAYOUT.get();
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/DrawableColorPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/DrawableColorPatch.java
new file mode 100644
index 0000000000..d728074580
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/DrawableColorPatch.java
@@ -0,0 +1,22 @@
+package app.revanced.extension.music.patches.utils;
+
+@SuppressWarnings("unused")
+public class DrawableColorPatch {
+ private static final int[] DARK_VALUES = {
+ -14606047 // comments box background
+ };
+
+ public static int getColor(int originalValue) {
+ if (anyEquals(originalValue, DARK_VALUES))
+ return -16777215;
+
+ return originalValue;
+ }
+
+ private static boolean anyEquals(int value, int... of) {
+ for (int v : of) if (value == v) return true;
+ return false;
+ }
+}
+
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/InitializationPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/InitializationPatch.java
new file mode 100644
index 0000000000..4524a10d02
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/InitializationPatch.java
@@ -0,0 +1,34 @@
+package app.revanced.extension.music.patches.utils;
+
+import static app.revanced.extension.music.utils.RestartUtils.showRestartDialog;
+
+import android.app.Activity;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.music.utils.ExtendedUtils;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.utils.Utils;
+
+@SuppressWarnings("unused")
+public class InitializationPatch {
+
+ /**
+ * The new layout is not loaded normally when the app is first installed.
+ * (Also reproduced on unPatched YouTube Music)
+ *
+ * To fix this, show the reboot dialog when the app is installed for the first time.
+ */
+ public static void onCreate(@NonNull Activity mActivity) {
+ if (BaseSettings.SETTINGS_INITIALIZED.get())
+ return;
+
+ showRestartDialog(mActivity, "revanced_extended_restart_first_run", 3000);
+ Utils.runOnMainThreadDelayed(() -> BaseSettings.SETTINGS_INITIALIZED.save(true), 3000);
+ }
+
+ public static void setDeviceInformation(@NonNull Activity mActivity) {
+ ExtendedUtils.setApplicationLabel();
+ ExtendedUtils.setVersionName();
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/PatchStatus.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/PatchStatus.java
new file mode 100644
index 0000000000..08abcf94a0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/PatchStatus.java
@@ -0,0 +1,12 @@
+package app.revanced.extension.music.patches.utils;
+
+@SuppressWarnings("unused")
+public class PatchStatus {
+ public static boolean SpoofAppVersionDefaultBoolean() {
+ return false;
+ }
+
+ public static String SpoofAppVersionDefaultString() {
+ return "6.11.52";
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/PlayerTypeHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/PlayerTypeHookPatch.java
new file mode 100644
index 0000000000..1efe2d1a88
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/PlayerTypeHookPatch.java
@@ -0,0 +1,19 @@
+package app.revanced.extension.music.patches.utils;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.music.shared.PlayerType;
+
+@SuppressWarnings("unused")
+public class PlayerTypeHookPatch {
+ /**
+ * Injection point.
+ */
+ public static void setPlayerType(@Nullable Enum> musicPlayerType) {
+ if (musicPlayerType == null)
+ return;
+
+ PlayerType.setFromString(musicPlayerType.name());
+ }
+}
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/ReturnYouTubeDislikePatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/ReturnYouTubeDislikePatch.java
new file mode 100644
index 0000000000..b864a9b3fd
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/ReturnYouTubeDislikePatch.java
@@ -0,0 +1,112 @@
+package app.revanced.extension.music.patches.utils;
+
+import static app.revanced.extension.shared.returnyoutubedislike.ReturnYouTubeDislike.Vote;
+
+import android.text.Spanned;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.music.returnyoutubedislike.ReturnYouTubeDislike;
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.shared.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
+import app.revanced.extension.shared.utils.Logger;
+
+/**
+ * Handles all interaction of UI patch components.
+ *
+ * Does not handle creating dislike spans or anything to do with {@link ReturnYouTubeDislikeApi}.
+ */
+@SuppressWarnings("unused")
+public class ReturnYouTubeDislikePatch {
+ /**
+ * RYD data for the current video on screen.
+ */
+ @Nullable
+ private static volatile ReturnYouTubeDislike currentVideoData;
+
+ public static void onRYDStatusChange(boolean rydEnabled) {
+ ReturnYouTubeDislikeApi.resetRateLimits();
+ // Must remove all values to protect against using stale data
+ // if the user enables RYD while a video is on screen.
+ clearData();
+ }
+
+ private static void clearData() {
+ currentVideoData = null;
+ }
+
+ /**
+ * Injection point
+ *
+ * Called when a Shorts dislike Spannable is created
+ */
+ public static Spanned onSpannedCreated(Spanned original) {
+ try {
+ if (original == null) {
+ return null;
+ }
+ ReturnYouTubeDislike videoData = currentVideoData;
+ if (videoData == null) {
+ return original; // User enabled RYD while a video was on screen.
+ }
+ return videoData.getDislikesSpan(original);
+ } catch (Exception ex) {
+ Logger.printException(() -> "onSpannedCreated failure", ex);
+ }
+ return original;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void newVideoLoaded(@Nullable String videoId) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) {
+ return;
+ }
+ if (videoId == null || videoId.isEmpty()) {
+ return;
+ }
+ if (videoIdIsSame(currentVideoData, videoId)) {
+ return;
+ }
+ currentVideoData = ReturnYouTubeDislike.getFetchForVideoId(videoId);
+ } catch (Exception ex) {
+ Logger.printException(() -> "newVideoLoaded failure", ex);
+ }
+ }
+
+ private static boolean videoIdIsSame(@Nullable ReturnYouTubeDislike fetch, @Nullable String videoId) {
+ return (fetch == null && videoId == null)
+ || (fetch != null && fetch.getVideoId().equals(videoId));
+ }
+
+ /**
+ * Injection point.
+ *
+ * Called when the user likes or dislikes.
+ */
+ public static void sendVote(int vote) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) {
+ return;
+ }
+ ReturnYouTubeDislike videoData = currentVideoData;
+ if (videoData == null) {
+ Logger.printDebug(() -> "Cannot send vote, as current video data is null");
+ return; // User enabled RYD while a regular video was minimized.
+ }
+
+ for (Vote v : Vote.values()) {
+ if (v.value == vote) {
+ videoData.sendVote(v);
+
+ return;
+ }
+ }
+ Logger.printException(() -> "Unknown vote type: " + vote);
+ } catch (Exception ex) {
+ Logger.printException(() -> "sendVote failure", ex);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/VideoTypeHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/VideoTypeHookPatch.java
new file mode 100644
index 0000000000..c6c3e90c90
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/utils/VideoTypeHookPatch.java
@@ -0,0 +1,19 @@
+package app.revanced.extension.music.patches.utils;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.music.shared.VideoType;
+
+@SuppressWarnings("unused")
+public class VideoTypeHookPatch {
+ /**
+ * Injection point.
+ */
+ public static void setVideoType(@Nullable Enum> musicVideoType) {
+ if (musicVideoType == null)
+ return;
+
+ VideoType.setFromString(musicVideoType.name());
+ }
+}
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/CustomPlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/CustomPlaybackSpeedPatch.java
new file mode 100644
index 0000000000..4e5fc0d331
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/CustomPlaybackSpeedPatch.java
@@ -0,0 +1,94 @@
+package app.revanced.extension.music.patches.video;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+@SuppressWarnings("unused")
+public class CustomPlaybackSpeedPatch {
+ /**
+ * Maximum playback speed, exclusive value. Custom speeds must be less than this value.
+ */
+ private static final float MAXIMUM_PLAYBACK_SPEED = 5;
+
+ /**
+ * Custom playback speeds.
+ */
+ private static float[] customPlaybackSpeeds;
+
+ static {
+ loadCustomSpeeds();
+ }
+
+ /**
+ * Injection point.
+ */
+ public static float[] getArray(float[] original) {
+ return userChangedCustomPlaybackSpeed() ? customPlaybackSpeeds : original;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static int getLength(int original) {
+ return userChangedCustomPlaybackSpeed() ? customPlaybackSpeeds.length : original;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static int getSize(int original) {
+ return userChangedCustomPlaybackSpeed() ? 0 : original;
+ }
+
+ private static void resetCustomSpeeds(@NonNull String toastMessage) {
+ Utils.showToastLong(toastMessage);
+ Utils.showToastShort(str("revanced_extended_reset_to_default_toast"));
+ Settings.CUSTOM_PLAYBACK_SPEEDS.resetToDefault();
+ }
+
+ public static void loadCustomSpeeds() {
+ try {
+ String[] speedStrings = Settings.CUSTOM_PLAYBACK_SPEEDS.get().split("\\s+");
+ Arrays.sort(speedStrings);
+ if (speedStrings.length == 0) {
+ throw new IllegalArgumentException();
+ }
+ customPlaybackSpeeds = new float[speedStrings.length];
+ for (int i = 0, length = speedStrings.length; i < length; i++) {
+ final float speed = Float.parseFloat(speedStrings[i]);
+ if (speed <= 0 || arrayContains(customPlaybackSpeeds, speed)) {
+ throw new IllegalArgumentException();
+ }
+ if (speed > MAXIMUM_PLAYBACK_SPEED) {
+ resetCustomSpeeds(str("revanced_custom_playback_speeds_invalid", MAXIMUM_PLAYBACK_SPEED + ""));
+ loadCustomSpeeds();
+ return;
+ }
+ customPlaybackSpeeds[i] = speed;
+ }
+ } catch (Exception ex) {
+ Logger.printInfo(() -> "parse error", ex);
+ resetCustomSpeeds(str("revanced_custom_playback_speeds_parse_exception"));
+ loadCustomSpeeds();
+ }
+ }
+
+ private static boolean userChangedCustomPlaybackSpeed() {
+ return !Settings.CUSTOM_PLAYBACK_SPEEDS.isSetToDefault() && customPlaybackSpeeds != null;
+ }
+
+ private static boolean arrayContains(float[] array, float value) {
+ for (float arrayValue : array) {
+ if (arrayValue == value) return true;
+ }
+ return false;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/PlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/PlaybackSpeedPatch.java
new file mode 100644
index 0000000000..843f0c84e7
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/PlaybackSpeedPatch.java
@@ -0,0 +1,32 @@
+package app.revanced.extension.music.patches.video;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.shared.utils.Utils.showToastShort;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.shared.utils.Logger;
+
+@SuppressWarnings("unused")
+public class PlaybackSpeedPatch {
+
+ public static float getPlaybackSpeed(final float playbackSpeed) {
+ try {
+ return Settings.DEFAULT_PLAYBACK_SPEED.get();
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to getPlaybackSpeed", ex);
+ }
+ return playbackSpeed;
+ }
+
+ public static void userSelectedPlaybackSpeed(final float playbackSpeed) {
+ if (!Settings.REMEMBER_PLAYBACK_SPEED_LAST_SELECTED.get())
+ return;
+
+ Settings.DEFAULT_PLAYBACK_SPEED.save(playbackSpeed);
+
+ if (!Settings.REMEMBER_PLAYBACK_SPEED_LAST_SELECTED_TOAST.get())
+ return;
+
+ showToastShort(str("revanced_remember_playback_speed_toast", playbackSpeed + "x"));
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/VideoQualityPatch.java b/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/VideoQualityPatch.java
new file mode 100644
index 0000000000..d9e7f8819a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/patches/video/VideoQualityPatch.java
@@ -0,0 +1,68 @@
+package app.revanced.extension.music.patches.video;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.music.shared.VideoInformation;
+import app.revanced.extension.shared.settings.IntegerSetting;
+import app.revanced.extension.shared.utils.Utils;
+
+@SuppressWarnings("unused")
+public class VideoQualityPatch {
+ private static final int DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY = -2;
+ private static final IntegerSetting mobileQualitySetting = Settings.DEFAULT_VIDEO_QUALITY_MOBILE;
+ private static final IntegerSetting wifiQualitySetting = Settings.DEFAULT_VIDEO_QUALITY_WIFI;
+
+ /**
+ * Injection point.
+ */
+ public static void newVideoStarted(final String ignoredVideoId) {
+ final int preferredQuality =
+ Utils.getNetworkType() == Utils.NetworkType.MOBILE
+ ? mobileQualitySetting.get()
+ : wifiQualitySetting.get();
+
+ if (preferredQuality == DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY)
+ return;
+
+ Utils.runOnMainThreadDelayed(() ->
+ VideoInformation.overrideVideoQuality(
+ VideoInformation.getAvailableVideoQuality(preferredQuality)
+ ),
+ 500
+ );
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void userSelectedVideoQuality() {
+ Utils.runOnMainThreadDelayed(() ->
+ userSelectedVideoQuality(VideoInformation.getVideoQuality()),
+ 300
+ );
+ }
+
+ private static void userSelectedVideoQuality(final int defaultQuality) {
+ if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED.get())
+ return;
+ if (defaultQuality == DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY)
+ return;
+
+ final Utils.NetworkType networkType = Utils.getNetworkType();
+
+ switch (networkType) {
+ case NONE -> {
+ Utils.showToastShort(str("revanced_remember_video_quality_none"));
+ return;
+ }
+ case MOBILE -> mobileQualitySetting.save(defaultQuality);
+ default -> wifiQualitySetting.save(defaultQuality);
+ }
+
+ if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED_TOAST.get())
+ return;
+
+ Utils.showToastShort(str("revanced_remember_video_quality_" + networkType.getName(), defaultQuality + "p"));
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/returnyoutubedislike/ReturnYouTubeDislike.java b/extensions/shared/src/main/java/app/revanced/extension/music/returnyoutubedislike/ReturnYouTubeDislike.java
new file mode 100644
index 0000000000..bec27d1b3b
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/returnyoutubedislike/ReturnYouTubeDislike.java
@@ -0,0 +1,564 @@
+package app.revanced.extension.music.returnyoutubedislike;
+
+import static app.revanced.extension.shared.returnyoutubedislike.ReturnYouTubeDislike.Vote;
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.shared.utils.Utils.isSDKAbove;
+
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.OvalShape;
+import android.graphics.drawable.shapes.RectShape;
+import android.icu.text.CompactDecimalFormat;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.ImageSpan;
+import android.util.DisplayMetrics;
+import android.util.TypedValue;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.text.NumberFormat;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.shared.returnyoutubedislike.requests.RYDVoteData;
+import app.revanced.extension.shared.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+/**
+ * Because Litho creates spans using multiple threads, this entire class supports multithreading as well.
+ */
+public class ReturnYouTubeDislike {
+
+ /**
+ * Maximum amount of time to block the UI from updates while waiting for network call to complete.
+ *
+ * Must be less than 5 seconds, as per:
+ *
+ */
+ private static final long MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH = 4000;
+
+ /**
+ * How long to retain successful RYD fetches.
+ */
+ private static final long CACHE_TIMEOUT_SUCCESS_MILLISECONDS = 7 * 60 * 1000; // 7 Minutes
+
+ /**
+ * How long to retain unsuccessful RYD fetches,
+ * and also the minimum time before retrying again.
+ */
+ private static final long CACHE_TIMEOUT_FAILURE_MILLISECONDS = 3 * 60 * 1000; // 3 Minutes
+
+ /**
+ * Unique placeholder character, used to detect if a segmented span already has dislikes added to it.
+ * Can be any almost any non-visible character.
+ */
+ private static final char MIDDLE_SEPARATOR_CHARACTER = '◎'; // 'bullseye'
+
+ private static final int SEPARATOR_COLOR = 872415231;
+
+ /**
+ * Cached lookup of all video ids.
+ */
+ @GuardedBy("itself")
+ private static final Map fetchCache = new HashMap<>();
+
+ /**
+ * Used to send votes, one by one, in the same order the user created them.
+ */
+ private static final ExecutorService voteSerialExecutor = Executors.newSingleThreadExecutor();
+
+ /**
+ * For formatting dislikes as number.
+ */
+ @GuardedBy("ReturnYouTubeDislike.class") // not thread safe
+ private static CompactDecimalFormat dislikeCountFormatter;
+
+ /**
+ * For formatting dislikes as percentage.
+ */
+ @GuardedBy("ReturnYouTubeDislike.class")
+ private static NumberFormat dislikePercentageFormatter;
+
+ public static final Rect leftSeparatorBounds;
+ private static final Rect middleSeparatorBounds;
+
+ static {
+ DisplayMetrics dp = Objects.requireNonNull(Utils.getContext()).getResources().getDisplayMetrics();
+
+ leftSeparatorBounds = new Rect(0, 0,
+ (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.2f, dp),
+ (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 25, dp));
+ final int middleSeparatorSize =
+ (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.7f, dp);
+ middleSeparatorBounds = new Rect(0, 0, middleSeparatorSize, middleSeparatorSize);
+
+ ReturnYouTubeDislikeApi.toastOnConnectionError = Settings.RYD_TOAST_ON_CONNECTION_ERROR.get();
+ }
+
+ private final String videoId;
+
+ /**
+ * Stores the results of the vote api fetch, and used as a barrier to wait until fetch completes.
+ * Absolutely cannot be holding any lock during calls to {@link Future#get()}.
+ */
+ private final Future future;
+
+ /**
+ * Time this instance and the fetch future was created.
+ */
+ private final long timeFetched;
+
+ /**
+ * Optional current vote status of the UI. Used to apply a user vote that was done on a previous video viewing.
+ */
+ @Nullable
+ @GuardedBy("this")
+ private Vote userVote;
+
+ /**
+ * Original dislike span, before modifications.
+ */
+ @Nullable
+ @GuardedBy("this")
+ private Spanned originalDislikeSpan;
+
+ /**
+ * Replacement like/dislike span that includes formatted dislikes.
+ * Used to prevent recreating the same span multiple times.
+ */
+ @Nullable
+ @GuardedBy("this")
+ private SpannableString replacementLikeDislikeSpan;
+
+ @NonNull
+ private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable,
+ @NonNull RYDVoteData voteData) {
+ CharSequence oldLikes = oldSpannable;
+
+ // YouTube creators can hide the like count on a video,
+ // and the like count appears as a device language specific string that says 'Like'.
+ // Check if the string contains any numbers.
+ if (!Utils.containsNumber(oldLikes)) {
+ if (Settings.RYD_ESTIMATED_LIKE.get()) {
+ // Likes are hidden by video creator
+ //
+ // RYD does not directly provide like data, but can use an estimated likes
+ // using the same scale factor RYD applied to the raw dislikes.
+ //
+ // example video: https://www.youtube.com/watch?v=UnrU5vxCHxw
+ // RYD data: https://returnyoutubedislikeapi.com/votes?videoId=UnrU5vxCHxw
+ Logger.printDebug(() -> "Using estimated likes");
+ oldLikes = formatDislikeCount(voteData.getLikeCount());
+ } else {
+ // Change the "Likes" string to show that likes and dislikes are hidden.
+ String hiddenMessageString = str("revanced_ryd_video_likes_hidden_by_video_owner");
+ return newSpanUsingStylingOfAnotherSpan(oldSpannable, hiddenMessageString);
+ }
+ }
+
+ SpannableStringBuilder builder = new SpannableStringBuilder("\u2009\u2009");
+ final boolean compactLayout = Settings.RYD_COMPACT_LAYOUT.get();
+
+ if (!compactLayout) {
+ String leftSeparatorString = "\u200E "; // u200E = left to right character
+ Spannable leftSeparatorSpan = new SpannableString(leftSeparatorString);
+ ShapeDrawable shapeDrawable = new ShapeDrawable(new RectShape());
+ shapeDrawable.getPaint().setColor(SEPARATOR_COLOR);
+ shapeDrawable.setBounds(leftSeparatorBounds);
+ leftSeparatorSpan.setSpan(new VerticallyCenteredImageSpan(shapeDrawable), 1, 2,
+ Spannable.SPAN_INCLUSIVE_EXCLUSIVE); // drawable cannot overwrite RTL or LTR character
+ builder.append(leftSeparatorSpan);
+ }
+
+ // likes
+ builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikes));
+
+ // middle separator
+ String middleSeparatorString = compactLayout
+ ? "\u200E " + MIDDLE_SEPARATOR_CHARACTER + " "
+ : "\u200E \u2009" + MIDDLE_SEPARATOR_CHARACTER + "\u2009 "; // u2009 = 'narrow space' character
+ final int shapeInsertionIndex = middleSeparatorString.length() / 2;
+ Spannable middleSeparatorSpan = new SpannableString(middleSeparatorString);
+ ShapeDrawable shapeDrawable = new ShapeDrawable(new OvalShape());
+ shapeDrawable.getPaint().setColor(SEPARATOR_COLOR);
+ shapeDrawable.setBounds(middleSeparatorBounds);
+ // Use original text width if using Rolling Number,
+ // to ensure the replacement styled span has the same width as the measured String,
+ // otherwise layout can be broken (especially on devices with small system font sizes).
+ middleSeparatorSpan.setSpan(
+ new VerticallyCenteredImageSpan(shapeDrawable),
+ shapeInsertionIndex, shapeInsertionIndex + 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
+ builder.append(middleSeparatorSpan);
+
+ // dislikes
+ builder.append(newSpannableWithDislikes(oldSpannable, voteData));
+
+ return new SpannableString(builder);
+ }
+
+ /**
+ * @return If the text is likely for a previously created likes/dislikes segmented span.
+ */
+ public static boolean isPreviouslyCreatedSegmentedSpan(@NonNull String text) {
+ return text.indexOf(MIDDLE_SEPARATOR_CHARACTER) >= 0;
+ }
+
+ private static boolean spansHaveEqualTextAndColor(@NonNull Spanned one, @NonNull Spanned two) {
+ // Cannot use equals on the span, because many of the inner styling spans do not implement equals.
+ // Instead, compare the underlying text and the text color to handle when dark mode is changed.
+ // Cannot compare the status of device dark mode, as Litho components are updated just before dark mode status changes.
+ if (!one.toString().equals(two.toString())) {
+ return false;
+ }
+ ForegroundColorSpan[] oneColors = one.getSpans(0, one.length(), ForegroundColorSpan.class);
+ ForegroundColorSpan[] twoColors = two.getSpans(0, two.length(), ForegroundColorSpan.class);
+ final int oneLength = oneColors.length;
+ if (oneLength != twoColors.length) {
+ return false;
+ }
+ for (int i = 0; i < oneLength; i++) {
+ if (oneColors[i].getForegroundColor() != twoColors[i].getForegroundColor()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static SpannableString newSpannableWithDislikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) {
+ return newSpanUsingStylingOfAnotherSpan(sourceStyling,
+ Settings.RYD_DISLIKE_PERCENTAGE.get()
+ ? formatDislikePercentage(voteData.getDislikePercentage())
+ : formatDislikeCount(voteData.getDislikeCount()));
+ }
+
+ private static SpannableString newSpanUsingStylingOfAnotherSpan(@NonNull Spanned sourceStyle, @NonNull CharSequence newSpanText) {
+ SpannableString destination = new SpannableString(newSpanText);
+ Object[] spans = sourceStyle.getSpans(0, sourceStyle.length(), Object.class);
+ for (Object span : spans) {
+ destination.setSpan(span, 0, destination.length(), sourceStyle.getSpanFlags(span));
+ }
+ return destination;
+ }
+
+ private static String formatDislikeCount(long dislikeCount) {
+ if (isSDKAbove(24)) {
+ if (dislikeCountFormatter == null) {
+ // Note: Java number formatters will use the locale specific number characters.
+ // such as Arabic which formats "1.234" into "۱,۲۳٤"
+ // But YouTube disregards locale specific number characters
+ // and instead shows english number characters everywhere.
+ Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().getLocales().get(0);
+ Logger.printDebug(() -> "Locale: " + locale);
+ dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT);
+ }
+ return dislikeCountFormatter.format(dislikeCount);
+ } else {
+ return String.valueOf(dislikeCount);
+ }
+ }
+
+ private static String formatDislikePercentage(float dislikePercentage) {
+ if (isSDKAbove(24)) {
+ synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
+ if (dislikePercentageFormatter == null) {
+ Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().getLocales().get(0);
+ Logger.printDebug(() -> "Locale: " + locale);
+ dislikePercentageFormatter = NumberFormat.getPercentInstance(locale);
+ }
+ if (dislikePercentage >= 0.01) { // at least 1%
+ dislikePercentageFormatter.setMaximumFractionDigits(0); // show only whole percentage points
+ } else {
+ dislikePercentageFormatter.setMaximumFractionDigits(1); // show up to 1 digit precision
+ }
+ return dislikePercentageFormatter.format(dislikePercentage);
+ }
+ } else {
+ return String.valueOf((int) (dislikePercentage * 100));
+ }
+ }
+
+ @NonNull
+ public static ReturnYouTubeDislike getFetchForVideoId(@Nullable String videoId) {
+ Objects.requireNonNull(videoId);
+ synchronized (fetchCache) {
+ // Remove any expired entries.
+ final long now = System.currentTimeMillis();
+ if (isSDKAbove(24)) {
+ fetchCache.values().removeIf(value -> {
+ final boolean expired = value.isExpired(now);
+ if (expired)
+ Logger.printDebug(() -> "Removing expired fetch: " + value.videoId);
+ return expired;
+ });
+ } else {
+ final Iterator> itr = fetchCache.entrySet().iterator();
+ while (itr.hasNext()) {
+ final Map.Entry entry = itr.next();
+ if (entry.getValue().isExpired(now)) {
+ Logger.printDebug(() -> "Removing expired fetch: " + entry.getValue().videoId);
+ itr.remove();
+ }
+ }
+ }
+
+ ReturnYouTubeDislike fetch = fetchCache.get(videoId);
+ if (fetch == null) {
+ fetch = new ReturnYouTubeDislike(videoId);
+ fetchCache.put(videoId, fetch);
+ }
+ return fetch;
+ }
+ }
+
+ /**
+ * Should be called if the user changes dislikes appearance settings.
+ */
+ public static void clearAllUICaches() {
+ synchronized (fetchCache) {
+ for (ReturnYouTubeDislike fetch : fetchCache.values()) {
+ fetch.clearUICache();
+ }
+ }
+ }
+
+ private ReturnYouTubeDislike(@NonNull String videoId) {
+ this.videoId = Objects.requireNonNull(videoId);
+ this.timeFetched = System.currentTimeMillis();
+ this.future = Utils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchVotes(videoId));
+ }
+
+ private boolean isExpired(long now) {
+ final long timeSinceCreation = now - timeFetched;
+ if (timeSinceCreation < CACHE_TIMEOUT_FAILURE_MILLISECONDS) {
+ return false; // Not expired, even if the API call failed.
+ }
+ if (timeSinceCreation > CACHE_TIMEOUT_SUCCESS_MILLISECONDS) {
+ return true; // Always expired.
+ }
+ // Only expired if the fetch failed (API null response).
+ return (!fetchCompleted() || getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH) == null);
+ }
+
+ @Nullable
+ public RYDVoteData getFetchData(long maxTimeToWait) {
+ try {
+ return future.get(maxTimeToWait, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ex) {
+ Logger.printDebug(() -> "Waited but future was not complete after: " + maxTimeToWait + "ms");
+ } catch (ExecutionException | InterruptedException ex) {
+ Logger.printException(() -> "Future failure ", ex); // will never happen
+ }
+ return null;
+ }
+
+ /**
+ * @return if the RYD fetch call has completed.
+ */
+ public boolean fetchCompleted() {
+ return future.isDone();
+ }
+
+ private synchronized void clearUICache() {
+ if (replacementLikeDislikeSpan != null) {
+ Logger.printDebug(() -> "Clearing replacement span for: " + videoId);
+ }
+ replacementLikeDislikeSpan = null;
+ }
+
+ /**
+ * Must call off main thread, as this will make a network call if user is not yet registered.
+ *
+ * @return ReturnYouTubeDislike user ID. If user registration has never happened
+ * and the network call fails, this returns NULL.
+ */
+ @Nullable
+ private static String getUserId() {
+ Utils.verifyOffMainThread();
+
+ String userId = Settings.RYD_USER_ID.get();
+ if (!userId.isEmpty()) {
+ return userId;
+ }
+
+ userId = ReturnYouTubeDislikeApi.registerAsNewUser();
+ if (userId != null) {
+ Settings.RYD_USER_ID.save(userId);
+ }
+ return userId;
+ }
+
+ @NonNull
+ public String getVideoId() {
+ return videoId;
+ }
+
+ /**
+ * @return the replacement span containing dislikes, or the original span if RYD is not available.
+ */
+ @NonNull
+ public synchronized Spanned getDislikesSpan(@NonNull Spanned original) {
+ return waitForFetchAndUpdateReplacementSpan(original);
+ }
+
+ @NonNull
+ private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original) {
+ try {
+ RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH);
+ if (votingData == null) {
+ Logger.printDebug(() -> "Cannot add dislike to UI (RYD data not available)");
+ return original;
+ }
+
+ synchronized (this) {
+ if (originalDislikeSpan != null && replacementLikeDislikeSpan != null) {
+ if (spansHaveEqualTextAndColor(original, replacementLikeDislikeSpan)) {
+ Logger.printDebug(() -> "Ignoring previously created dislikes span of data: " + videoId);
+ return original;
+ }
+ if (spansHaveEqualTextAndColor(original, originalDislikeSpan)) {
+ Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId);
+ return replacementLikeDislikeSpan;
+ }
+ }
+ if (isPreviouslyCreatedSegmentedSpan(original.toString())) {
+ // need to recreate using original, as original has prior outdated dislike values
+ if (originalDislikeSpan == null) {
+ // Should never happen.
+ Logger.printDebug(() -> "Cannot add dislikes - original span is null. videoId: " + videoId);
+ return original;
+ }
+ original = originalDislikeSpan;
+ }
+
+ // No replacement span exist, create it now.
+
+ if (userVote != null) {
+ votingData.updateUsingVote(userVote);
+ }
+ originalDislikeSpan = original;
+ replacementLikeDislikeSpan = createDislikeSpan(original, votingData);
+ Logger.printDebug(() -> "Replaced: '" + originalDislikeSpan + "' with: '"
+ + replacementLikeDislikeSpan + "'" + " using video: " + videoId);
+
+ return replacementLikeDislikeSpan;
+ }
+ } catch (Exception e) {
+ Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", e); // should never happen
+ }
+ return original;
+ }
+
+ public void sendVote(@NonNull Vote vote) {
+ Utils.verifyOnMainThread();
+ Objects.requireNonNull(vote);
+ try {
+ setUserVote(vote);
+
+ voteSerialExecutor.execute(() -> {
+ try { // Must wrap in try/catch to properly log exceptions.
+ ReturnYouTubeDislikeApi.sendVote(getUserId(), videoId, vote);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to send vote", ex);
+ }
+ });
+ } catch (Exception ex) {
+ Logger.printException(() -> "Error trying to send vote", ex);
+ }
+ }
+
+ /**
+ * Sets the current user vote value, and does not send the vote to the RYD API.
+ *
+ * Only used to set value if thumbs up/down is already selected on video load.
+ */
+ public void setUserVote(@NonNull Vote vote) {
+ Objects.requireNonNull(vote);
+ try {
+ Logger.printDebug(() -> "setUserVote: " + vote);
+
+ synchronized (this) {
+ userVote = vote;
+ clearUICache();
+ }
+
+ if (future.isDone()) {
+ // Update the fetched vote data.
+ RYDVoteData voteData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH);
+ if (voteData == null) {
+ // RYD fetch failed.
+ Logger.printDebug(() -> "Cannot update UI (vote data not available)");
+ return;
+ }
+ voteData.updateUsingVote(vote);
+ } // Else, vote will be applied after fetch completes.
+
+ } catch (Exception ex) {
+ Logger.printException(() -> "setUserVote failure", ex);
+ }
+ }
+}
+
+/**
+ * Vertically centers a Spanned Drawable.
+ */
+class VerticallyCenteredImageSpan extends ImageSpan {
+
+ public VerticallyCenteredImageSpan(Drawable drawable) {
+ super(drawable);
+ }
+
+ @Override
+ public int getSize(@NonNull Paint paint, @NonNull CharSequence text,
+ int start, int end, @Nullable Paint.FontMetricsInt fontMetrics) {
+ Drawable drawable = getDrawable();
+ Rect bounds = drawable.getBounds();
+ if (fontMetrics != null) {
+ Paint.FontMetricsInt paintMetrics = paint.getFontMetricsInt();
+ final int fontHeight = paintMetrics.descent - paintMetrics.ascent;
+ final int drawHeight = bounds.bottom - bounds.top;
+ final int halfDrawHeight = drawHeight / 2;
+ final int yCenter = paintMetrics.ascent + fontHeight / 2;
+
+ fontMetrics.ascent = yCenter - halfDrawHeight;
+ fontMetrics.top = fontMetrics.ascent;
+ fontMetrics.bottom = yCenter + halfDrawHeight;
+ fontMetrics.descent = fontMetrics.bottom;
+ }
+ return bounds.right;
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end,
+ float x, int top, int y, int bottom, @NonNull Paint paint) {
+ Drawable drawable = getDrawable();
+ canvas.save();
+ Paint.FontMetricsInt paintMetrics = paint.getFontMetricsInt();
+ final int fontHeight = paintMetrics.descent - paintMetrics.ascent;
+ final int yCenter = y + paintMetrics.descent - fontHeight / 2;
+ final Rect drawBounds = drawable.getBounds();
+ final int translateY = yCenter - (drawBounds.bottom - drawBounds.top) / 2;
+ canvas.translate(x, translateY);
+ drawable.draw(canvas);
+ canvas.restore();
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/ActivityHook.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/ActivityHook.java
new file mode 100644
index 0000000000..628c44ed6c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/ActivityHook.java
@@ -0,0 +1,80 @@
+package app.revanced.extension.music.settings;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+
+import java.lang.ref.WeakReference;
+
+import app.revanced.extension.music.settings.preference.ReVancedPreferenceFragment;
+import app.revanced.extension.shared.utils.Logger;
+
+/**
+ * @noinspection ALL
+ */
+public class ActivityHook {
+ private static WeakReference activityRef = new WeakReference<>(null);
+
+ public static Activity getActivity() {
+ return activityRef.get();
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param object object is usually Activity, but sometimes object cannot be cast to Activity.
+ * Check whether object can be cast as Activity for a safe hook.
+ */
+ public static void setActivity(@NonNull Object object) {
+ if (object instanceof Activity mActivity) {
+ activityRef = new WeakReference<>(mActivity);
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param baseActivity Activity containing intent data.
+ * It should be finished immediately after obtaining the dataString.
+ * @return Whether or not dataString is included.
+ */
+ public static boolean initialize(@NonNull Activity baseActivity) {
+ try {
+ final Intent baseActivityIntent = baseActivity.getIntent();
+ if (baseActivityIntent == null)
+ return false;
+
+ // If we do not finish the activity immediately, the YT Music logo will remain on the screen.
+ baseActivity.finish();
+
+ String dataString = baseActivityIntent.getDataString();
+ if (dataString == null || dataString.isEmpty())
+ return false;
+
+ // Checks whether dataString contains settings that use Intent.
+ if (!Settings.includeWithIntent(dataString))
+ return false;
+
+
+ // Save intent data in settings activity.
+ Activity mActivity = activityRef.get();
+ Intent intent = mActivity.getIntent();
+ intent.setData(Uri.parse(dataString));
+ mActivity.setIntent(intent);
+
+ // Starts a new PreferenceFragment to handle activities freely.
+ mActivity.getFragmentManager()
+ .beginTransaction()
+ .add(new ReVancedPreferenceFragment(), "")
+ .commit();
+
+ return true;
+ } catch (Exception ex) {
+ Logger.printException(() -> "initializeSettings failure", ex);
+ }
+ return false;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/Settings.java
new file mode 100644
index 0000000000..9618a2ea0d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/Settings.java
@@ -0,0 +1,251 @@
+package app.revanced.extension.music.settings;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+import static app.revanced.extension.music.sponsorblock.objects.CategoryBehaviour.SKIP_AUTOMATICALLY;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.music.patches.utils.PatchStatus;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.FloatSetting;
+import app.revanced.extension.shared.settings.IntegerSetting;
+import app.revanced.extension.shared.settings.LongSetting;
+import app.revanced.extension.shared.settings.StringSetting;
+import app.revanced.extension.shared.utils.Utils;
+
+
+@SuppressWarnings("unused")
+public class Settings extends BaseSettings {
+ // PreferenceScreen: Account
+ public static final BooleanSetting HIDE_ACCOUNT_MENU = new BooleanSetting("revanced_hide_account_menu", FALSE);
+ public static final StringSetting HIDE_ACCOUNT_MENU_FILTER_STRINGS = new StringSetting("revanced_hide_account_menu_filter_strings", "");
+ public static final BooleanSetting HIDE_ACCOUNT_MENU_EMPTY_COMPONENT = new BooleanSetting("revanced_hide_account_menu_empty_component", FALSE);
+ public static final BooleanSetting HIDE_HANDLE = new BooleanSetting("revanced_hide_handle", TRUE, true);
+ public static final BooleanSetting HIDE_TERMS_CONTAINER = new BooleanSetting("revanced_hide_terms_container", FALSE);
+
+
+ // PreferenceScreen: Action Bar
+ public static final BooleanSetting HIDE_ACTION_BUTTON_LIKE_DISLIKE = new BooleanSetting("revanced_hide_action_button_like_dislike", FALSE, true);
+ public static final BooleanSetting HIDE_ACTION_BUTTON_COMMENT = new BooleanSetting("revanced_hide_action_button_comment", FALSE, true);
+ public static final BooleanSetting HIDE_ACTION_BUTTON_ADD_TO_PLAYLIST = new BooleanSetting("revanced_hide_action_button_add_to_playlist", FALSE, true);
+ public static final BooleanSetting HIDE_ACTION_BUTTON_DOWNLOAD = new BooleanSetting("revanced_hide_action_button_download", FALSE, true);
+ public static final BooleanSetting HIDE_ACTION_BUTTON_SHARE = new BooleanSetting("revanced_hide_action_button_share", FALSE, true);
+ public static final BooleanSetting HIDE_ACTION_BUTTON_RADIO = new BooleanSetting("revanced_hide_action_button_radio", FALSE, true);
+ public static final BooleanSetting HIDE_ACTION_BUTTON_LABEL = new BooleanSetting("revanced_hide_action_button_label", FALSE, true);
+ public static final BooleanSetting EXTERNAL_DOWNLOADER_ACTION_BUTTON = new BooleanSetting("revanced_external_downloader_action", FALSE, true);
+ public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME = new StringSetting("revanced_external_downloader_package_name", "com.deniscerri.ytdl");
+
+
+ // PreferenceScreen: Ads
+ public static final BooleanSetting HIDE_GENERAL_ADS = new BooleanSetting("revanced_hide_general_ads", TRUE, true);
+ public static final BooleanSetting HIDE_MUSIC_ADS = new BooleanSetting("revanced_hide_music_ads", TRUE, true);
+ public static final BooleanSetting HIDE_PAID_PROMOTION_LABEL = new BooleanSetting("revanced_hide_paid_promotion_label", TRUE, true);
+ public static final BooleanSetting HIDE_PREMIUM_PROMOTION = new BooleanSetting("revanced_hide_premium_promotion", TRUE, true);
+ public static final BooleanSetting HIDE_PREMIUM_RENEWAL = new BooleanSetting("revanced_hide_premium_renewal", TRUE, true);
+
+
+ // PreferenceScreen: Flyout menu
+ public static final BooleanSetting ENABLE_COMPACT_DIALOG = new BooleanSetting("revanced_enable_compact_dialog", TRUE);
+ public static final BooleanSetting ENABLE_TRIM_SILENCE = new BooleanSetting("revanced_enable_trim_silence", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_LIKE_DISLIKE = new BooleanSetting("revanced_hide_flyout_menu_like_dislike", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_3_COLUMN_COMPONENT = new BooleanSetting("revanced_hide_flyout_menu_3_column_component", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_ADD_TO_QUEUE = new BooleanSetting("revanced_hide_flyout_menu_add_to_queue", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_CAPTIONS = new BooleanSetting("revanced_hide_flyout_menu_captions", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_DELETE_PLAYLIST = new BooleanSetting("revanced_hide_flyout_menu_delete_playlist", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_DISMISS_QUEUE = new BooleanSetting("revanced_hide_flyout_menu_dismiss_queue", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_DOWNLOAD = new BooleanSetting("revanced_hide_flyout_menu_download", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_EDIT_PLAYLIST = new BooleanSetting("revanced_hide_flyout_menu_edit_playlist", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_GO_TO_ALBUM = new BooleanSetting("revanced_hide_flyout_menu_go_to_album", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_GO_TO_ARTIST = new BooleanSetting("revanced_hide_flyout_menu_go_to_artist", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_GO_TO_EPISODE = new BooleanSetting("revanced_hide_flyout_menu_go_to_episode", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_GO_TO_PODCAST = new BooleanSetting("revanced_hide_flyout_menu_go_to_podcast", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_HELP = new BooleanSetting("revanced_hide_flyout_menu_help", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_PLAY_NEXT = new BooleanSetting("revanced_hide_flyout_menu_play_next", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_QUALITY = new BooleanSetting("revanced_hide_flyout_menu_quality", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_REMOVE_FROM_LIBRARY = new BooleanSetting("revanced_hide_flyout_menu_remove_from_library", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_REMOVE_FROM_PLAYLIST = new BooleanSetting("revanced_hide_flyout_menu_remove_from_playlist", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_REPORT = new BooleanSetting("revanced_hide_flyout_menu_report", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_SAVE_EPISODE_FOR_LATER = new BooleanSetting("revanced_hide_flyout_menu_save_episode_for_later", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_SAVE_TO_LIBRARY = new BooleanSetting("revanced_hide_flyout_menu_save_to_library", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_SAVE_TO_PLAYLIST = new BooleanSetting("revanced_hide_flyout_menu_save_to_playlist", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_SHARE = new BooleanSetting("revanced_hide_flyout_menu_share", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_SHUFFLE_PLAY = new BooleanSetting("revanced_hide_flyout_menu_shuffle_play", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_SLEEP_TIMER = new BooleanSetting("revanced_hide_flyout_menu_sleep_timer", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_START_RADIO = new BooleanSetting("revanced_hide_flyout_menu_start_radio", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_STATS_FOR_NERDS = new BooleanSetting("revanced_hide_flyout_menu_stats_for_nerds", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_SUBSCRIBE = new BooleanSetting("revanced_hide_flyout_menu_subscribe", FALSE, true);
+ public static final BooleanSetting HIDE_FLYOUT_MENU_VIEW_SONG_CREDIT = new BooleanSetting("revanced_hide_flyout_menu_view_song_credit", FALSE, true);
+ public static final BooleanSetting REPLACE_FLYOUT_MENU_DISMISS_QUEUE = new BooleanSetting("revanced_replace_flyout_menu_dismiss_queue", FALSE, true);
+ public static final BooleanSetting REPLACE_FLYOUT_MENU_DISMISS_QUEUE_CONTINUE_WATCH = new BooleanSetting("revanced_replace_flyout_menu_dismiss_queue_continue_watch", TRUE);
+ public static final BooleanSetting REPLACE_FLYOUT_MENU_REPORT = new BooleanSetting("revanced_replace_flyout_menu_report", TRUE, true);
+ public static final BooleanSetting REPLACE_FLYOUT_MENU_REPORT_ONLY_PLAYER = new BooleanSetting("revanced_replace_flyout_menu_report_only_player", TRUE, true);
+
+
+ // PreferenceScreen: General
+ public static final StringSetting CHANGE_START_PAGE = new StringSetting("revanced_change_start_page", "FEmusic_home", true);
+ public static final BooleanSetting DISABLE_DISLIKE_REDIRECTION = new BooleanSetting("revanced_disable_dislike_redirection", FALSE);
+ public static final BooleanSetting ENABLE_LANDSCAPE_MODE = new BooleanSetting("revanced_enable_landscape_mode", FALSE, true);
+ public static final BooleanSetting CUSTOM_FILTER = new BooleanSetting("revanced_custom_filter", FALSE);
+ public static final StringSetting CUSTOM_FILTER_STRINGS = new StringSetting("revanced_custom_filter_strings", "", true);
+ public static final BooleanSetting HIDE_BUTTON_SHELF = new BooleanSetting("revanced_hide_button_shelf", FALSE, true);
+ public static final BooleanSetting HIDE_CAROUSEL_SHELF = new BooleanSetting("revanced_hide_carousel_shelf", FALSE, true);
+ public static final BooleanSetting HIDE_PLAYLIST_CARD_SHELF = new BooleanSetting("revanced_hide_playlist_card_shelf", FALSE, true);
+ public static final BooleanSetting HIDE_SAMPLE_SHELF = new BooleanSetting("revanced_hide_samples_shelf", FALSE, true);
+ public static final BooleanSetting HIDE_CAST_BUTTON = new BooleanSetting("revanced_hide_cast_button", TRUE);
+ public static final BooleanSetting HIDE_CATEGORY_BAR = new BooleanSetting("revanced_hide_category_bar", FALSE, true);
+ public static final BooleanSetting HIDE_FLOATING_BUTTON = new BooleanSetting("revanced_hide_floating_button", FALSE, true);
+ public static final BooleanSetting HIDE_TAP_TO_UPDATE_BUTTON = new BooleanSetting("revanced_hide_tap_to_update_button", FALSE, true);
+ public static final BooleanSetting HIDE_HISTORY_BUTTON = new BooleanSetting("revanced_hide_history_button", FALSE);
+ public static final BooleanSetting HIDE_NOTIFICATION_BUTTON = new BooleanSetting("revanced_hide_notification_button", FALSE, true);
+ public static final BooleanSetting HIDE_SOUND_SEARCH_BUTTON = new BooleanSetting("revanced_hide_sound_search_button", FALSE, true);
+ public static final BooleanSetting HIDE_VOICE_SEARCH_BUTTON = new BooleanSetting("revanced_hide_voice_search_button", FALSE, true);
+ public static final BooleanSetting REMOVE_VIEWER_DISCRETION_DIALOG = new BooleanSetting("revanced_remove_viewer_discretion_dialog", FALSE);
+ public static final BooleanSetting RESTORE_OLD_STYLE_LIBRARY_SHELF = new BooleanSetting("revanced_restore_old_style_library_shelf", FALSE, true);
+ public static final BooleanSetting SPOOF_APP_VERSION = new BooleanSetting("revanced_spoof_app_version",
+ PatchStatus.SpoofAppVersionDefaultBoolean(), true);
+ public static final StringSetting SPOOF_APP_VERSION_TARGET = new StringSetting("revanced_spoof_app_version_target",
+ PatchStatus.SpoofAppVersionDefaultString(), true);
+
+
+ // PreferenceScreen: Navigation bar
+ public static final BooleanSetting ENABLE_BLACK_NAVIGATION_BAR = new BooleanSetting("revanced_enable_black_navigation_bar", TRUE);
+ public static final BooleanSetting HIDE_NAVIGATION_HOME_BUTTON = new BooleanSetting("revanced_hide_navigation_home_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_SAMPLES_BUTTON = new BooleanSetting("revanced_hide_navigation_samples_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_EXPLORE_BUTTON = new BooleanSetting("revanced_hide_navigation_explore_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_LIBRARY_BUTTON = new BooleanSetting("revanced_hide_navigation_library_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_UPGRADE_BUTTON = new BooleanSetting("revanced_hide_navigation_upgrade_button", TRUE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR = new BooleanSetting("revanced_hide_navigation_bar", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_LABEL = new BooleanSetting("revanced_hide_navigation_label", FALSE, true);
+
+
+ // PreferenceScreen: Player
+ public static final BooleanSetting DISABLE_MINI_PLAYER_GESTURE = new BooleanSetting("revanced_disable_mini_player_gesture", FALSE, true);
+ public static final BooleanSetting DISABLE_PLAYER_GESTURE = new BooleanSetting("revanced_disable_player_gesture", FALSE, true);
+ public static final BooleanSetting ENABLE_BLACK_PLAYER_BACKGROUND = new BooleanSetting("revanced_enable_black_player_background", FALSE, true);
+ public static final BooleanSetting ENABLE_COLOR_MATCH_PLAYER = new BooleanSetting("revanced_enable_color_match_player", TRUE);
+ public static final BooleanSetting ENABLE_FORCE_MINIMIZED_PLAYER = new BooleanSetting("revanced_enable_force_minimized_player", TRUE);
+ public static final BooleanSetting ENABLE_MINI_PLAYER_NEXT_BUTTON = new BooleanSetting("revanced_enable_mini_player_next_button", TRUE, true);
+ public static final BooleanSetting ENABLE_MINI_PLAYER_PREVIOUS_BUTTON = new BooleanSetting("revanced_enable_mini_player_previous_button", TRUE, true);
+ public static final BooleanSetting ENABLE_SWIPE_TO_DISMISS_MINI_PLAYER = new BooleanSetting("revanced_enable_swipe_to_dismiss_mini_player", TRUE, true);
+ public static final BooleanSetting ENABLE_ZEN_MODE = new BooleanSetting("revanced_enable_zen_mode", FALSE);
+ public static final BooleanSetting ENABLE_ZEN_MODE_PODCAST = new BooleanSetting("revanced_enable_zen_mode_podcast", FALSE);
+ public static final BooleanSetting HIDE_AUDIO_VIDEO_SWITCH_TOGGLE = new BooleanSetting("revanced_hide_audio_video_switch_toggle", FALSE, true);
+ public static final BooleanSetting HIDE_COMMENT_CHANNEL_GUIDELINES = new BooleanSetting("revanced_hide_comment_channel_guidelines", TRUE);
+ public static final BooleanSetting HIDE_COMMENT_TIMESTAMP_AND_EMOJI_BUTTONS = new BooleanSetting("revanced_hide_comment_timestamp_and_emoji_buttons", FALSE);
+ public static final BooleanSetting HIDE_DOUBLE_TAP_OVERLAY_FILTER = new BooleanSetting("revanced_hide_double_tap_overlay_filter", FALSE, true);
+ public static final BooleanSetting HIDE_FULLSCREEN_SHARE_BUTTON = new BooleanSetting("revanced_hide_fullscreen_share_button", FALSE, true);
+ public static final BooleanSetting REMEMBER_REPEAT_SATE = new BooleanSetting("revanced_remember_repeat_state", TRUE);
+ public static final BooleanSetting REMEMBER_SHUFFLE_SATE = new BooleanSetting("revanced_remember_shuffle_state", TRUE);
+ public static final BooleanSetting ALWAYS_SHUFFLE = new BooleanSetting("revanced_always_shuffle", FALSE);
+ public static final BooleanSetting RESTORE_OLD_COMMENTS_POPUP_PANELS = new BooleanSetting("revanced_restore_old_comments_popup_panels", FALSE, true);
+ public static final BooleanSetting RESTORE_OLD_PLAYER_BACKGROUND = new BooleanSetting("revanced_restore_old_player_background", FALSE, true);
+ public static final BooleanSetting RESTORE_OLD_PLAYER_LAYOUT = new BooleanSetting("revanced_restore_old_player_layout", FALSE, true);
+
+
+ // PreferenceScreen: Settings menu
+ public static final BooleanSetting HIDE_SETTINGS_MENU_PARENT_TOOLS = new BooleanSetting("revanced_hide_settings_menu_parent_tools", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_GENERAL = new BooleanSetting("revanced_hide_settings_menu_general", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_PLAYBACK = new BooleanSetting("revanced_hide_settings_menu_playback", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_DATA_SAVING = new BooleanSetting("revanced_hide_settings_menu_data_saving", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_DOWNLOADS_AND_STORAGE = new BooleanSetting("revanced_hide_settings_menu_downloads_and_storage", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_NOTIFICATIONS = new BooleanSetting("revanced_hide_settings_menu_notification", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_PRIVACY_AND_LOCATION = new BooleanSetting("revanced_hide_settings_menu_privacy_and_location", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_RECOMMENDATIONS = new BooleanSetting("revanced_hide_settings_menu_recommendations", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_PAID_MEMBERSHIPS = new BooleanSetting("revanced_hide_settings_menu_paid_memberships", TRUE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_ABOUT = new BooleanSetting("revanced_hide_settings_menu_about", FALSE, true);
+
+
+ // PreferenceScreen: Video
+ public static final StringSetting CUSTOM_PLAYBACK_SPEEDS = new StringSetting("revanced_custom_playback_speeds", "0.5\n0.8\n1.0\n1.2\n1.5\n1.8\n2.0", true);
+ public static final BooleanSetting REMEMBER_PLAYBACK_SPEED_LAST_SELECTED = new BooleanSetting("revanced_remember_playback_speed_last_selected", TRUE);
+ public static final BooleanSetting REMEMBER_PLAYBACK_SPEED_LAST_SELECTED_TOAST = new BooleanSetting("revanced_remember_playback_speed_last_selected_toast", TRUE);
+ public static final BooleanSetting REMEMBER_VIDEO_QUALITY_LAST_SELECTED = new BooleanSetting("revanced_remember_video_quality_last_selected", TRUE);
+ public static final BooleanSetting REMEMBER_VIDEO_QUALITY_LAST_SELECTED_TOAST = new BooleanSetting("revanced_remember_video_quality_last_selected_toast", TRUE);
+ public static final FloatSetting DEFAULT_PLAYBACK_SPEED = new FloatSetting("revanced_default_playback_speed", 1.0f);
+ public static final IntegerSetting DEFAULT_VIDEO_QUALITY_MOBILE = new IntegerSetting("revanced_default_video_quality_mobile", -2);
+ public static final IntegerSetting DEFAULT_VIDEO_QUALITY_WIFI = new IntegerSetting("revanced_default_video_quality_wifi", -2);
+
+
+ // PreferenceScreen: Miscellaneous
+ public static final BooleanSetting CHANGE_SHARE_SHEET = new BooleanSetting("revanced_change_share_sheet", FALSE, true);
+ public static final BooleanSetting DISABLE_CAIRO_SPLASH_ANIMATION = new BooleanSetting("revanced_disable_cairo_splash_animation", FALSE, true);
+ public static final BooleanSetting ENABLE_OPUS_CODEC = new BooleanSetting("revanced_enable_opus_codec", FALSE, true);
+ public static final BooleanSetting SETTINGS_IMPORT_EXPORT = new BooleanSetting("revanced_extended_settings_import_export", FALSE, false);
+
+
+ // PreferenceScreen: Return YouTube Dislike
+ public static final BooleanSetting RYD_ENABLED = new BooleanSetting("revanced_ryd_enabled", TRUE);
+ public static final StringSetting RYD_USER_ID = new StringSetting("revanced_ryd_user_id", "", false, false);
+ public static final BooleanSetting RYD_DISLIKE_PERCENTAGE = new BooleanSetting("revanced_ryd_dislike_percentage", FALSE);
+ public static final BooleanSetting RYD_COMPACT_LAYOUT = new BooleanSetting("revanced_ryd_compact_layout", FALSE);
+ public static final BooleanSetting RYD_ESTIMATED_LIKE = new BooleanSetting("revanced_ryd_estimated_like", FALSE, true);
+ public static final BooleanSetting RYD_TOAST_ON_CONNECTION_ERROR = new BooleanSetting("revanced_ryd_toast_on_connection_error", FALSE);
+
+ // PreferenceScreen: Return YouTube Username
+ public static final BooleanSetting RETURN_YOUTUBE_USERNAME_ABOUT = new BooleanSetting("revanced_return_youtube_username_youtube_data_api_v3_about", FALSE, false);
+
+
+ // PreferenceScreen: SponsorBlock
+ public static final BooleanSetting SB_ENABLED = new BooleanSetting("sb_enabled", TRUE);
+ public static final BooleanSetting SB_TOAST_ON_CONNECTION_ERROR = new BooleanSetting("sb_toast_on_connection_error", FALSE);
+ public static final BooleanSetting SB_TOAST_ON_SKIP = new BooleanSetting("sb_toast_on_skip", TRUE);
+ public static final StringSetting SB_API_URL = new StringSetting("sb_api_url", "https://sponsor.ajay.app");
+ public static final StringSetting SB_PRIVATE_USER_ID = new StringSetting("sb_private_user_id", "");
+ public static final BooleanSetting SB_USER_IS_VIP = new BooleanSetting("sb_user_is_vip", FALSE);
+
+ public static final StringSetting SB_CATEGORY_SPONSOR = new StringSetting("sb_sponsor", SKIP_AUTOMATICALLY.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_SPONSOR_COLOR = new StringSetting("sb_sponsor_color", "#00D400");
+ public static final StringSetting SB_CATEGORY_SELF_PROMO = new StringSetting("sb_selfpromo", SKIP_AUTOMATICALLY.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_SELF_PROMO_COLOR = new StringSetting("sb_selfpromo_color", "#FFFF00");
+ public static final StringSetting SB_CATEGORY_INTERACTION = new StringSetting("sb_interaction", SKIP_AUTOMATICALLY.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_INTERACTION_COLOR = new StringSetting("sb_interaction_color", "#CC00FF");
+ public static final StringSetting SB_CATEGORY_INTRO = new StringSetting("sb_intro", SKIP_AUTOMATICALLY.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_INTRO_COLOR = new StringSetting("sb_intro_color", "#00FFFF");
+ public static final StringSetting SB_CATEGORY_OUTRO = new StringSetting("sb_outro", SKIP_AUTOMATICALLY.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_OUTRO_COLOR = new StringSetting("sb_outro_color", "#0202ED");
+ public static final StringSetting SB_CATEGORY_PREVIEW = new StringSetting("sb_preview", SKIP_AUTOMATICALLY.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_PREVIEW_COLOR = new StringSetting("sb_preview_color", "#008FD6");
+ public static final StringSetting SB_CATEGORY_FILLER = new StringSetting("sb_filler", SKIP_AUTOMATICALLY.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_FILLER_COLOR = new StringSetting("sb_filler_color", "#7300FF");
+ public static final StringSetting SB_CATEGORY_MUSIC_OFFTOPIC = new StringSetting("sb_music_offtopic", SKIP_AUTOMATICALLY.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_MUSIC_OFFTOPIC_COLOR = new StringSetting("sb_music_offtopic_color", "#FF9900");
+
+ // SB settings not exported
+ public static final LongSetting SB_LAST_VIP_CHECK = new LongSetting("sb_last_vip_check", 0L, false, false);
+
+ public static final String OPEN_DEFAULT_APP_SETTINGS = "revanced_default_app_settings";
+
+ /**
+ * If a setting path has this prefix, then remove it.
+ */
+ public static final String OPTIONAL_SPONSOR_BLOCK_SETTINGS_PREFIX = "sb_segments_";
+
+ /**
+ * Array of settings using intent
+ */
+ private static final String[] intentSettingArray = new String[]{
+ BYPASS_IMAGE_REGION_RESTRICTIONS_DOMAIN.key,
+ CHANGE_START_PAGE.key,
+ CUSTOM_FILTER_STRINGS.key,
+ CUSTOM_PLAYBACK_SPEEDS.key,
+ EXTERNAL_DOWNLOADER_PACKAGE_NAME.key,
+ HIDE_ACCOUNT_MENU_FILTER_STRINGS.key,
+ SB_API_URL.key,
+ SETTINGS_IMPORT_EXPORT.key,
+ SPOOF_APP_VERSION_TARGET.key,
+ RETURN_YOUTUBE_USERNAME_ABOUT.key,
+ RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT.key,
+ RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY.key,
+ OPEN_DEFAULT_APP_SETTINGS,
+ OPTIONAL_SPONSOR_BLOCK_SETTINGS_PREFIX
+ };
+
+ /**
+ * @return whether dataString contains settings that use Intent
+ */
+ public static boolean includeWithIntent(@NonNull String dataString) {
+ return Utils.containsAny(dataString, intentSettingArray);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ExternalDownloaderPreference.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ExternalDownloaderPreference.java
new file mode 100644
index 0000000000..0454f14240
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ExternalDownloaderPreference.java
@@ -0,0 +1,132 @@
+package app.revanced.extension.music.settings.preference;
+
+import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder;
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Intent;
+import android.net.Uri;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.util.TypedValue;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TableLayout;
+import android.widget.TableRow;
+
+import java.util.Arrays;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.music.utils.ExtendedUtils;
+import app.revanced.extension.shared.settings.StringSetting;
+import app.revanced.extension.shared.utils.ResourceUtils;
+import app.revanced.extension.shared.utils.Utils;
+
+/**
+ * @noinspection all
+ */
+public class ExternalDownloaderPreference {
+
+ private static final StringSetting settings = Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME;
+ private static final String[] mEntries = ResourceUtils.getStringArray("revanced_external_downloader_label");
+ private static final String[] mEntryValues = ResourceUtils.getStringArray("revanced_external_downloader_package_name");
+ private static final String[] mWebsiteEntries = ResourceUtils.getStringArray("revanced_external_downloader_website");
+ private static EditText mEditText;
+ private static String packageName;
+ private static int mClickedDialogEntryIndex;
+
+ private static final TextWatcher textWatcher = new TextWatcher() {
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ public void afterTextChanged(Editable s) {
+ packageName = s.toString();
+ mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName);
+ }
+ };
+
+ public static void showDialog(Activity mActivity) {
+ packageName = settings.get().toString();
+ mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName);
+
+ AlertDialog.Builder builder = getDialogBuilder(mActivity);
+
+ TableLayout table = new TableLayout(mActivity);
+ table.setOrientation(LinearLayout.HORIZONTAL);
+ table.setPadding(15, 0, 15, 0);
+
+ TableRow row = new TableRow(mActivity);
+
+ mEditText = new EditText(mActivity);
+ mEditText.setText(packageName);
+ mEditText.addTextChangedListener(textWatcher);
+ mEditText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 9);
+ mEditText.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT, 1f));
+ row.addView(mEditText);
+
+ table.addView(row);
+ builder.setView(table);
+
+ builder.setTitle(str("revanced_external_downloader_dialog_title"));
+ builder.setSingleChoiceItems(mEntries, mClickedDialogEntryIndex, (dialog, which) -> {
+ mClickedDialogEntryIndex = which;
+ mEditText.setText(mEntryValues[which].toString());
+ });
+ builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ final String packageName = mEditText.getText().toString().trim();
+ settings.save(packageName);
+ checkPackageIsValid(mActivity, packageName);
+ dialog.dismiss();
+ });
+ builder.setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> settings.resetToDefault());
+ builder.setNegativeButton(android.R.string.cancel, null);
+
+ builder.show();
+ }
+
+ private static boolean checkPackageIsValid(Activity mActivity, String packageName) {
+ String appName = "";
+ String website = "";
+ if (mClickedDialogEntryIndex >= 0) {
+ appName = mEntries[mClickedDialogEntryIndex].toString();
+ website = mWebsiteEntries[mClickedDialogEntryIndex].toString();
+ return showToastOrOpenWebsites(mActivity, appName, packageName, website);
+ } else {
+ return showToastOrOpenWebsites(mActivity, appName, packageName, website);
+ }
+ }
+
+ private static boolean showToastOrOpenWebsites(Activity mActivity, String appName, String packageName, String website) {
+ if (ExtendedUtils.isPackageEnabled(packageName))
+ return true;
+
+ if (website.isEmpty()) {
+ Utils.showToastShort(str("revanced_external_downloader_not_installed_warning", packageName));
+ return false;
+ }
+
+ getDialogBuilder(mActivity)
+ .setTitle(str("revanced_external_downloader_not_installed_dialog_title"))
+ .setMessage(str("revanced_external_downloader_not_installed_dialog_message", appName, appName))
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(website));
+ mActivity.startActivity(i);
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+
+ return false;
+ }
+
+ public static boolean checkPackageIsEnabled() {
+ final Activity mActivity = Utils.getActivity();
+ packageName = settings.get().toString();
+ mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName);
+ return checkPackageIsValid(mActivity, packageName);
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ReVancedPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ReVancedPreferenceFragment.java
new file mode 100644
index 0000000000..a819e425bd
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ReVancedPreferenceFragment.java
@@ -0,0 +1,351 @@
+package app.revanced.extension.music.settings.preference;
+
+import static app.revanced.extension.music.settings.Settings.BYPASS_IMAGE_REGION_RESTRICTIONS_DOMAIN;
+import static app.revanced.extension.music.settings.Settings.CHANGE_START_PAGE;
+import static app.revanced.extension.music.settings.Settings.CUSTOM_FILTER_STRINGS;
+import static app.revanced.extension.music.settings.Settings.CUSTOM_PLAYBACK_SPEEDS;
+import static app.revanced.extension.music.settings.Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME;
+import static app.revanced.extension.music.settings.Settings.HIDE_ACCOUNT_MENU_FILTER_STRINGS;
+import static app.revanced.extension.music.settings.Settings.OPEN_DEFAULT_APP_SETTINGS;
+import static app.revanced.extension.music.settings.Settings.OPTIONAL_SPONSOR_BLOCK_SETTINGS_PREFIX;
+import static app.revanced.extension.music.settings.Settings.RETURN_YOUTUBE_USERNAME_ABOUT;
+import static app.revanced.extension.music.settings.Settings.SB_API_URL;
+import static app.revanced.extension.music.settings.Settings.SETTINGS_IMPORT_EXPORT;
+import static app.revanced.extension.music.settings.Settings.SPOOF_APP_VERSION_TARGET;
+import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder;
+import static app.revanced.extension.music.utils.ExtendedUtils.getLayoutParams;
+import static app.revanced.extension.music.utils.RestartUtils.showRestartDialog;
+import static app.revanced.extension.shared.settings.BaseSettings.RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT;
+import static app.revanced.extension.shared.settings.BaseSettings.RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY;
+import static app.revanced.extension.shared.settings.Setting.getSettingFromPath;
+import static app.revanced.extension.shared.utils.ResourceUtils.getStringArray;
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.shared.utils.Utils.isSDKAbove;
+import static app.revanced.extension.shared.utils.Utils.showToastShort;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.icu.text.SimpleDateFormat;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.PreferenceFragment;
+import android.text.InputType;
+import android.util.TypedValue;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+
+import androidx.annotation.Nullable;
+
+import com.google.android.material.textfield.TextInputLayout;
+
+import java.io.BufferedReader;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.Date;
+import java.util.Objects;
+
+import app.revanced.extension.music.patches.utils.ReturnYouTubeDislikePatch;
+import app.revanced.extension.music.returnyoutubedislike.ReturnYouTubeDislike;
+import app.revanced.extension.music.settings.ActivityHook;
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.music.utils.ExtendedUtils;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.EnumSetting;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.settings.StringSetting;
+import app.revanced.extension.shared.settings.preference.YouTubeDataAPIDialogBuilder;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+@SuppressWarnings("all")
+public class ReVancedPreferenceFragment extends PreferenceFragment {
+
+ private static final String IMPORT_EXPORT_SETTINGS_ENTRY_KEY = "revanced_extended_settings_import_export_entries";
+ private static final int READ_REQUEST_CODE = 42;
+ private static final int WRITE_REQUEST_CODE = 43;
+
+ private static String existingSettings;
+
+
+ public ReVancedPreferenceFragment() {
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void onPreferenceChanged(@Nullable String key, boolean newValue) {
+ if (key == null || key.isEmpty())
+ return;
+
+ if (key.equals(Settings.RESTORE_OLD_PLAYER_LAYOUT.key) && newValue) {
+ Settings.RESTORE_OLD_PLAYER_BACKGROUND.save(newValue);
+ } else if (key.equals(Settings.RYD_ENABLED.key)) {
+ ReturnYouTubeDislikePatch.onRYDStatusChange(newValue);
+ } else if (key.equals(Settings.RYD_DISLIKE_PERCENTAGE.key) || key.equals(Settings.RYD_COMPACT_LAYOUT.key)) {
+ ReturnYouTubeDislike.clearAllUICaches();
+ }
+
+ for (Setting> setting : Setting.allLoadedSettings()) {
+ if (key.equals(setting.key)) {
+ ((BooleanSetting) setting).save(newValue);
+ if (setting.rebootApp) {
+ showRebootDialog();
+ }
+ break;
+ }
+ }
+ }
+
+ public static void showRebootDialog() {
+ final Activity activity = ActivityHook.getActivity();
+ if (activity == null)
+ return;
+
+ showRestartDialog(activity);
+ }
+
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ try {
+ final Activity baseActivity = this.getActivity();
+ final Activity mActivity = ActivityHook.getActivity();
+ final Intent savedInstanceStateIntent = baseActivity.getIntent();
+ if (savedInstanceStateIntent == null)
+ return;
+
+ final String dataString = savedInstanceStateIntent.getDataString();
+ if (dataString == null || dataString.isEmpty())
+ return;
+
+ if (dataString.startsWith(OPTIONAL_SPONSOR_BLOCK_SETTINGS_PREFIX)) {
+ SponsorBlockCategoryPreference.showDialog(baseActivity, dataString.replaceAll(OPTIONAL_SPONSOR_BLOCK_SETTINGS_PREFIX, ""));
+ return;
+ } else if (dataString.equals(OPEN_DEFAULT_APP_SETTINGS)) {
+ openDefaultAppSetting();
+ return;
+ }
+
+ final Setting> settings = getSettingFromPath(dataString);
+ if (settings instanceof StringSetting stringSetting) {
+ if (settings.equals(CHANGE_START_PAGE)) {
+ ResettableListPreference.showDialog(mActivity, stringSetting, 2);
+ } else if (settings.equals(BYPASS_IMAGE_REGION_RESTRICTIONS_DOMAIN)
+ || settings.equals(CUSTOM_FILTER_STRINGS)
+ || settings.equals(CUSTOM_PLAYBACK_SPEEDS)
+ || settings.equals(HIDE_ACCOUNT_MENU_FILTER_STRINGS)
+ || settings.equals(RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY)) {
+ ResettableEditTextPreference.showDialog(mActivity, stringSetting);
+ } else if (settings.equals(EXTERNAL_DOWNLOADER_PACKAGE_NAME)) {
+ ExternalDownloaderPreference.showDialog(mActivity);
+ } else if (settings.equals(SB_API_URL)) {
+ SponsorBlockApiUrlPreference.showDialog(mActivity);
+ } else if (settings.equals(SPOOF_APP_VERSION_TARGET)) {
+ ResettableListPreference.showDialog(mActivity, stringSetting, 0);
+ } else {
+ Logger.printDebug(() -> "Failed to find the right value: " + dataString);
+ }
+ } else if (settings instanceof BooleanSetting) {
+ if (settings.equals(SETTINGS_IMPORT_EXPORT)) {
+ importExportListDialogBuilder();
+ } else if (settings.equals(RETURN_YOUTUBE_USERNAME_ABOUT)) {
+ YouTubeDataAPIDialogBuilder.showDialog(mActivity);
+ } else {
+ Logger.printDebug(() -> "Failed to find the right value: " + dataString);
+ }
+ } else if (settings instanceof EnumSetting> enumSetting) {
+ if (settings.equals(RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT)) {
+ ResettableListPreference.showDialog(mActivity, enumSetting, 0);
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onCreate failure", ex);
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ }
+
+ private void openDefaultAppSetting() {
+ try {
+ Context context = getActivity();
+ final Uri uri = Uri.parse("package:" + context.getPackageName());
+ final Intent intent = isSDKAbove(31)
+ ? new Intent(android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS, uri)
+ : new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri);
+ context.startActivity(intent);
+ } catch (Exception exception) {
+ Logger.printException(() -> "openDefaultAppSetting failed");
+ }
+ }
+
+ /**
+ * Build a ListDialog for Import / Export settings
+ * When importing/exporting as file, {@link #onActivityResult} is used, so declare it here.
+ */
+ private void importExportListDialogBuilder() {
+ try {
+ final Activity activity = getActivity();
+ final String[] mEntries = getStringArray(IMPORT_EXPORT_SETTINGS_ENTRY_KEY);
+
+ getDialogBuilder(activity)
+ .setTitle(str("revanced_extended_settings_import_export_title"))
+ .setItems(mEntries, (dialog, index) -> {
+ switch (index) {
+ case 0 -> exportActivity();
+ case 1 -> importActivity();
+ case 2 -> importExportEditTextDialogBuilder();
+ }
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "importExportListDialogBuilder failure", ex);
+ }
+ }
+
+ /**
+ * Build a EditTextDialog for Import / Export settings
+ */
+ private void importExportEditTextDialogBuilder() {
+ try {
+ final Activity activity = getActivity();
+ final EditText textView = new EditText(activity);
+ existingSettings = Setting.exportToJson(null);
+ textView.setText(existingSettings);
+ textView.setInputType(textView.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ textView.setTextSize(TypedValue.COMPLEX_UNIT_PT, 8); // Use a smaller font to reduce text wrap.
+
+ TextInputLayout textInputLayout = new TextInputLayout(activity);
+ textInputLayout.setLayoutParams(getLayoutParams());
+ textInputLayout.addView(textView);
+
+ FrameLayout container = new FrameLayout(activity);
+ container.addView(textInputLayout);
+
+ getDialogBuilder(activity)
+ .setTitle(str("revanced_extended_settings_import_export_title"))
+ .setView(container)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setNeutralButton(str("revanced_extended_settings_import_copy"), (dialog, which) -> Utils.setClipboard(textView.getText().toString(), str("revanced_share_copy_settings_success")))
+ .setPositiveButton(str("revanced_extended_settings_import"), (dialog, which) -> importSettings(textView.getText().toString()))
+ .show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "importExportEditTextDialogBuilder failure", ex);
+ }
+ }
+
+ /**
+ * Invoke the SAF(Storage Access Framework) to export settings
+ */
+ private void exportActivity() {
+ @SuppressLint("SimpleDateFormat")
+ SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+
+ var appName = ExtendedUtils.getApplicationLabel();
+ var versionName = ExtendedUtils.getVersionName();
+ var formatDate = dateFormat.format(new Date(System.currentTimeMillis()));
+ var fileName = String.format("%s_v%s_%s.txt", appName, versionName, formatDate);
+
+ var intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType("text/plain");
+ intent.putExtra(Intent.EXTRA_TITLE, fileName);
+ startActivityForResult(intent, WRITE_REQUEST_CODE);
+ }
+
+ /**
+ * Invoke the SAF(Storage Access Framework) to import settings
+ */
+ private void importActivity() {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType(isSDKAbove(29) ? "text/plain" : "*/*");
+ startActivityForResult(intent, READ_REQUEST_CODE);
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode == WRITE_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
+ exportText(data.getData());
+ } else if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK && data != null) {
+ importText(data.getData());
+ }
+ }
+
+ private void exportText(Uri uri) {
+ try {
+ final Context context = this.getContext();
+
+ @SuppressLint("Recycle")
+ FileWriter jsonFileWriter =
+ new FileWriter(
+ Objects.requireNonNull(context.getApplicationContext()
+ .getContentResolver()
+ .openFileDescriptor(uri, "w"))
+ .getFileDescriptor()
+ );
+ PrintWriter printWriter = new PrintWriter(jsonFileWriter);
+ printWriter.write(Setting.exportToJson(null));
+ printWriter.close();
+ jsonFileWriter.close();
+
+ showToastShort(str("revanced_extended_settings_export_success"));
+ } catch (IOException e) {
+ showToastShort(str("revanced_extended_settings_export_failed"));
+ }
+ }
+
+ private void importText(Uri uri) {
+ final Context context = this.getContext();
+ StringBuilder sb = new StringBuilder();
+ String line;
+
+ try {
+ @SuppressLint("Recycle")
+ FileReader fileReader =
+ new FileReader(
+ Objects.requireNonNull(context.getApplicationContext()
+ .getContentResolver()
+ .openFileDescriptor(uri, "r"))
+ .getFileDescriptor()
+ );
+ BufferedReader bufferedReader = new BufferedReader(fileReader);
+ while ((line = bufferedReader.readLine()) != null) {
+ sb.append(line).append("\n");
+ }
+ bufferedReader.close();
+ fileReader.close();
+
+ final boolean restartNeeded = Setting.importFromJSON(sb.toString(), false);
+ if (restartNeeded) {
+ ReVancedPreferenceFragment.showRebootDialog();
+ }
+ } catch (IOException e) {
+ showToastShort(str("revanced_extended_settings_import_failed"));
+ throw new RuntimeException(e);
+ }
+ }
+
+ private void importSettings(String replacementSettings) {
+ try {
+ existingSettings = Setting.exportToJson(null);
+ if (replacementSettings.equals(existingSettings)) {
+ return;
+ }
+ final boolean restartNeeded = Setting.importFromJSON(replacementSettings, false);
+ if (restartNeeded) {
+ ReVancedPreferenceFragment.showRebootDialog();
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "importSettings failure", ex);
+ }
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ResettableEditTextPreference.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ResettableEditTextPreference.java
new file mode 100644
index 0000000000..d94bfab37e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ResettableEditTextPreference.java
@@ -0,0 +1,50 @@
+package app.revanced.extension.music.settings.preference;
+
+import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder;
+import static app.revanced.extension.music.utils.ExtendedUtils.getLayoutParams;
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.app.Activity;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+
+import com.google.android.material.textfield.TextInputLayout;
+
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.utils.Logger;
+
+public class ResettableEditTextPreference {
+
+ public static void showDialog(Activity mActivity, @NonNull Setting setting) {
+ try {
+ final EditText textView = new EditText(mActivity);
+ textView.setText(setting.get());
+
+ TextInputLayout textInputLayout = new TextInputLayout(mActivity);
+ textInputLayout.setLayoutParams(getLayoutParams());
+ textInputLayout.addView(textView);
+
+ FrameLayout container = new FrameLayout(mActivity);
+ container.addView(textInputLayout);
+
+ getDialogBuilder(mActivity)
+ .setTitle(str(setting.key + "_title"))
+ .setView(container)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> {
+ setting.resetToDefault();
+ ReVancedPreferenceFragment.showRebootDialog();
+ })
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ setting.save(textView.getText().toString().trim());
+ ReVancedPreferenceFragment.showRebootDialog();
+ })
+ .show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "showDialog failure", ex);
+ }
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ResettableListPreference.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ResettableListPreference.java
new file mode 100644
index 0000000000..b01f5bf2d1
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/ResettableListPreference.java
@@ -0,0 +1,81 @@
+package app.revanced.extension.music.settings.preference;
+
+import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder;
+import static app.revanced.extension.shared.utils.ResourceUtils.getStringArray;
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.app.Activity;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+
+import app.revanced.extension.shared.settings.EnumSetting;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.utils.Logger;
+
+public class ResettableListPreference {
+ private static int mClickedDialogEntryIndex;
+
+ public static void showDialog(Activity mActivity, @NonNull Setting setting, int defaultIndex) {
+ try {
+ final String settingsKey = setting.key;
+
+ final String entryKey = settingsKey + "_entries";
+ final String entryValueKey = settingsKey + "_entry_values";
+ final String[] mEntries = getStringArray(entryKey);
+ final String[] mEntryValues = getStringArray(entryValueKey);
+
+ final int findIndex = Arrays.binarySearch(mEntryValues, setting.get());
+ mClickedDialogEntryIndex = findIndex >= 0 ? findIndex : defaultIndex;
+
+ getDialogBuilder(mActivity)
+ .setTitle(str(settingsKey + "_title"))
+ .setSingleChoiceItems(mEntries, mClickedDialogEntryIndex,
+ (dialog, id) -> mClickedDialogEntryIndex = id)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> {
+ setting.resetToDefault();
+ ReVancedPreferenceFragment.showRebootDialog();
+ })
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ setting.save(mEntryValues[mClickedDialogEntryIndex]);
+ ReVancedPreferenceFragment.showRebootDialog();
+ })
+ .show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "showDialog failure", ex);
+ }
+ }
+
+ public static void showDialog(Activity mActivity, @NonNull EnumSetting> setting, int defaultIndex) {
+ try {
+ final String settingsKey = setting.key;
+
+ final String entryKey = settingsKey + "_entries";
+ final String entryValueKey = settingsKey + "_entry_values";
+ final String[] mEntries = getStringArray(entryKey);
+ final String[] mEntryValues = getStringArray(entryValueKey);
+
+ final int findIndex = Arrays.binarySearch(mEntryValues, setting.get().toString());
+ mClickedDialogEntryIndex = findIndex >= 0 ? findIndex : defaultIndex;
+
+ getDialogBuilder(mActivity)
+ .setTitle(str(settingsKey + "_title"))
+ .setSingleChoiceItems(mEntries, mClickedDialogEntryIndex,
+ (dialog, id) -> mClickedDialogEntryIndex = id)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> {
+ setting.resetToDefault();
+ ReVancedPreferenceFragment.showRebootDialog();
+ })
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ setting.saveValueFromString(mEntryValues[mClickedDialogEntryIndex]);
+ ReVancedPreferenceFragment.showRebootDialog();
+ })
+ .show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "showDialog failure", ex);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/SponsorBlockApiUrlPreference.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/SponsorBlockApiUrlPreference.java
new file mode 100644
index 0000000000..9b6c9a1a7b
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/SponsorBlockApiUrlPreference.java
@@ -0,0 +1,70 @@
+package app.revanced.extension.music.settings.preference;
+
+import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder;
+import static app.revanced.extension.music.utils.ExtendedUtils.getLayoutParams;
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.app.Activity;
+import android.util.Patterns;
+import android.widget.EditText;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+
+import com.google.android.material.textfield.TextInputLayout;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.shared.settings.StringSetting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+public class SponsorBlockApiUrlPreference {
+
+ public static void showDialog(Activity mActivity) {
+ try {
+ final StringSetting apiUrl = Settings.SB_API_URL;
+
+ final EditText textView = new EditText(mActivity);
+ textView.setText(apiUrl.get());
+
+ TextInputLayout textInputLayout = new TextInputLayout(mActivity);
+ textInputLayout.setLayoutParams(getLayoutParams());
+ textInputLayout.addView(textView);
+
+ FrameLayout container = new FrameLayout(mActivity);
+ container.addView(textInputLayout);
+
+ getDialogBuilder(mActivity)
+ .setTitle(str("revanced_sb_api_url"))
+ .setView(container)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> {
+ apiUrl.resetToDefault();
+ Utils.showToastShort(str("revanced_sb_api_url_reset"));
+ })
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ String serverAddress = textView.getText().toString().trim();
+ if (!isValidSBServerAddress(serverAddress)) {
+ Utils.showToastShort(str("revanced_sb_api_url_invalid"));
+ } else if (!serverAddress.equals(Settings.SB_API_URL.get())) {
+ apiUrl.save(serverAddress);
+ Utils.showToastShort(str("revanced_sb_api_url_changed"));
+ }
+ })
+ .show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "showDialog failure", ex);
+ }
+ }
+
+ public static boolean isValidSBServerAddress(@NonNull String serverAddress) {
+ if (!Patterns.WEB_URL.matcher(serverAddress).matches()) {
+ return false;
+ }
+ // Verify url is only the server address and does not contain a path such as: "https://sponsor.ajay.app/api/"
+ // Could use Patterns.compile, but this is simpler
+ final int lastDotIndex = serverAddress.lastIndexOf('.');
+ return lastDotIndex == -1 || !serverAddress.substring(lastDotIndex).contains("/");
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/SponsorBlockCategoryPreference.java b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/SponsorBlockCategoryPreference.java
new file mode 100644
index 0000000000..14dda2c783
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/settings/preference/SponsorBlockCategoryPreference.java
@@ -0,0 +1,124 @@
+package app.revanced.extension.music.settings.preference;
+
+import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder;
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.graphics.Color;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextWatcher;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TableLayout;
+import android.widget.TableRow;
+import android.widget.TextView;
+
+import java.util.Arrays;
+import java.util.Objects;
+
+import app.revanced.extension.music.sponsorblock.objects.CategoryBehaviour;
+import app.revanced.extension.music.sponsorblock.objects.SegmentCategory;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+public class SponsorBlockCategoryPreference {
+ private static final String[] CategoryBehaviourEntries = {str("revanced_sb_skip_automatically"), str("revanced_sb_skip_ignore")};
+ private static final CategoryBehaviour[] CategoryBehaviourEntryValues = {CategoryBehaviour.SKIP_AUTOMATICALLY, CategoryBehaviour.IGNORE};
+ private static int mClickedDialogEntryIndex;
+
+
+ public static void showDialog(Activity baseActivity, String categoryString) {
+ try {
+ SegmentCategory category = Objects.requireNonNull(SegmentCategory.byCategoryKey(categoryString));
+ final AlertDialog.Builder builder = getDialogBuilder(baseActivity);
+ TableLayout table = new TableLayout(baseActivity);
+ table.setOrientation(LinearLayout.HORIZONTAL);
+ table.setPadding(70, 0, 150, 0);
+
+ TableRow row = new TableRow(baseActivity);
+
+ TextView colorTextLabel = new TextView(baseActivity);
+ colorTextLabel.setText(str("revanced_sb_color_dot_label"));
+ row.addView(colorTextLabel);
+
+ TextView colorDotView = new TextView(baseActivity);
+ colorDotView.setText(category.getCategoryColorDot());
+ colorDotView.setPadding(30, 0, 30, 0);
+ row.addView(colorDotView);
+
+ final EditText mEditText = new EditText(baseActivity);
+ mEditText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS);
+ mEditText.setText(category.colorString());
+ mEditText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ try {
+ String colorString = s.toString();
+ if (!colorString.startsWith("#")) {
+ s.insert(0, "#"); // recursively calls back into this method
+ return;
+ }
+ if (colorString.length() > 7) {
+ s.delete(7, colorString.length());
+ return;
+ }
+ final int color = Color.parseColor(colorString);
+ colorDotView.setText(SegmentCategory.getCategoryColorDot(color));
+ } catch (IllegalArgumentException ex) {
+ // ignore
+ }
+ }
+ });
+ mEditText.setLayoutParams(new TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 1f));
+ row.addView(mEditText);
+
+ table.addView(row);
+ builder.setView(table);
+ builder.setTitle(category.title.toString());
+
+ builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ category.behaviour = CategoryBehaviourEntryValues[mClickedDialogEntryIndex];
+ category.setBehaviour(category.behaviour);
+ SegmentCategory.updateEnabledCategories();
+
+ String colorString = mEditText.getText().toString();
+ try {
+ if (!colorString.equals(category.colorString())) {
+ category.setColor(colorString);
+ Utils.showToastShort(str("revanced_sb_color_changed"));
+ }
+ } catch (IllegalArgumentException ex) {
+ Utils.showToastShort(str("revanced_sb_color_invalid"));
+ }
+ });
+ builder.setNeutralButton(str("revanced_sb_reset_color"), (dialog, which) -> {
+ try {
+ category.resetColor();
+ Utils.showToastShort(str("revanced_sb_color_reset"));
+ } catch (Exception ex) {
+ Logger.printException(() -> "setNeutralButton failure", ex);
+ }
+ });
+ builder.setNegativeButton(android.R.string.cancel, null);
+
+ final int index = Arrays.asList(CategoryBehaviourEntryValues).indexOf(category.behaviour);
+ mClickedDialogEntryIndex = Math.max(index, 0);
+
+ builder.setSingleChoiceItems(CategoryBehaviourEntries, mClickedDialogEntryIndex,
+ (dialog, id) -> mClickedDialogEntryIndex = id);
+ builder.show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "dialogBuilder failure", ex);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/shared/PlayerType.kt b/extensions/shared/src/main/java/app/revanced/extension/music/shared/PlayerType.kt
new file mode 100644
index 0000000000..5ca6ba944c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/shared/PlayerType.kt
@@ -0,0 +1,54 @@
+package app.revanced.extension.music.shared
+
+import app.revanced.extension.shared.utils.Event
+import app.revanced.extension.shared.utils.Logger
+
+/**
+ * WatchWhile player type
+ */
+enum class PlayerType {
+ DISMISSED,
+ MINIMIZED,
+ MAXIMIZED_NOW_PLAYING,
+ MAXIMIZED_PLAYER_ADDITIONAL_VIEW,
+ FULLSCREEN,
+ SLIDING_VERTICALLY,
+ QUEUE_EXPANDING,
+ SLIDING_HORIZONTALLY;
+
+ companion object {
+
+ private val nameToPlayerType = values().associateBy { it.name }
+
+ @JvmStatic
+ fun setFromString(enumName: String) {
+ val newType = nameToPlayerType[enumName]
+ if (newType == null) {
+ Logger.printException { "Unknown PlayerType encountered: $enumName" }
+ } else if (current != newType) {
+ Logger.printDebug { "PlayerType changed to: $newType" }
+ current = newType
+ }
+ }
+
+ /**
+ * The current player type.
+ */
+ @JvmStatic
+ var current
+ get() = currentPlayerType
+ private set(value) {
+ currentPlayerType = value
+ onChange(currentPlayerType)
+ }
+
+ @Volatile // value is read/write from different threads
+ private var currentPlayerType = MINIMIZED
+
+ /**
+ * player type change listener
+ */
+ @JvmStatic
+ val onChange = Event()
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/shared/VideoInformation.java b/extensions/shared/src/main/java/app/revanced/extension/music/shared/VideoInformation.java
new file mode 100644
index 0000000000..12ce65258a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/shared/VideoInformation.java
@@ -0,0 +1,319 @@
+package app.revanced.extension.music.shared;
+
+import static app.revanced.extension.shared.utils.ResourceUtils.getString;
+import static app.revanced.extension.shared.utils.Utils.getFormattedTimeStamp;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+/**
+ * Hooking class for the current playing video.
+ */
+@SuppressWarnings("unused")
+public final class VideoInformation {
+ private static final float DEFAULT_YOUTUBE_MUSIC_PLAYBACK_SPEED = 1.0f;
+ private static final int DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY = -2;
+ private static final String DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY_STRING = getString("quality_auto");
+ @NonNull
+ private static String videoId = "";
+
+ private static long videoLength = 0;
+ private static long videoTime = -1;
+
+ /**
+ * The current playback speed
+ */
+ private static float playbackSpeed = DEFAULT_YOUTUBE_MUSIC_PLAYBACK_SPEED;
+ /**
+ * The current video quality
+ */
+ private static int videoQuality = DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY;
+ /**
+ * The current video quality string
+ */
+ private static String videoQualityString = DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY_STRING;
+ /**
+ * The available qualities of the current video in human readable form: [1080, 720, 480]
+ */
+ @Nullable
+ private static List videoQualities;
+
+ /**
+ * Injection point.
+ */
+ public static void initialize() {
+ videoTime = -1;
+ videoLength = 0;
+ Logger.printDebug(() -> "Initialized Player");
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void initializeMdx() {
+ Logger.printDebug(() -> "Initialized Mdx Player");
+ }
+
+ /**
+ * Id of the current video playing. Includes Shorts and YouTube Stories.
+ *
+ * @return The id of the video. Empty string if not set yet.
+ */
+ @NonNull
+ public static String getVideoId() {
+ return videoId;
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param newlyLoadedVideoId id of the current video
+ */
+ public static void setVideoId(@NonNull String newlyLoadedVideoId) {
+ if (Objects.equals(newlyLoadedVideoId, videoId)) {
+ return;
+ }
+ Logger.printDebug(() -> "New video id: " + newlyLoadedVideoId);
+ videoId = newlyLoadedVideoId;
+ }
+
+ /**
+ * Seek on the current video.
+ * Does not function for playback of Shorts.
+ *
+ * Caution: If called from a videoTimeHook() callback,
+ * this will cause a recursive call into the same videoTimeHook() callback.
+ *
+ * @param seekTime The millisecond to seek the video to.
+ * @return if the seek was successful
+ */
+ public static boolean seekTo(final long seekTime) {
+ Utils.verifyOnMainThread();
+ try {
+ final long videoLength = getVideoLength();
+ final long videoTime = getVideoTime();
+ final long adjustedSeekTime = getAdjustedSeekTime(seekTime, videoLength);
+
+ if (videoTime <= 0 || videoLength <= 0) {
+ Logger.printDebug(() -> "Skipping seekTo as the video is not initialized");
+ return false;
+ }
+
+ Logger.printDebug(() -> "Seeking to: " + getFormattedTimeStamp(adjustedSeekTime));
+
+ // Try regular playback controller first, and it will not succeed if casting.
+ if (overrideVideoTime(adjustedSeekTime)) return true;
+ Logger.printDebug(() -> "seekTo did not succeeded. Trying MXD.");
+ // Else the video is loading or changing videos, or video is casting to a different device.
+
+ // Try calling the seekTo method of the MDX player director (called when casting).
+ // The difference has to be a different second mark in order to avoid infinite skip loops
+ // as the Lounge API only supports seconds.
+ if (adjustedSeekTime / 1000 == videoTime / 1000) {
+ Logger.printDebug(() -> "Skipping seekTo for MDX because seek time is too small "
+ + "(" + (adjustedSeekTime - videoTime) + "ms)");
+ return false;
+ }
+
+ return overrideMDXVideoTime(adjustedSeekTime);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to seek", ex);
+ return false;
+ }
+ }
+
+ // Prevent issues such as play/pause button or autoplay not working.
+ private static long getAdjustedSeekTime(final long seekTime, final long videoLength) {
+ // If the user skips to a section that is 500 ms before the video length,
+ // it will get stuck in a loop.
+ if (videoLength - seekTime > 500) {
+ return seekTime;
+ } else {
+ // Otherwise, just skips to a time longer than the video length.
+ // Paradoxically, if user skips to a section much longer than the video length, does not get stuck in a loop.
+ return Integer.MAX_VALUE;
+ }
+ }
+
+ /**
+ * @return The current playback speed.
+ */
+ public static float getPlaybackSpeed() {
+ return playbackSpeed;
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param newlyLoadedPlaybackSpeed The current playback speed.
+ */
+ public static void setPlaybackSpeed(float newlyLoadedPlaybackSpeed) {
+ playbackSpeed = newlyLoadedPlaybackSpeed;
+ }
+
+ /**
+ * @return The current video quality.
+ */
+ public static int getVideoQuality() {
+ return videoQuality;
+ }
+
+ /**
+ * @return The current video quality string.
+ */
+ public static String getVideoQualityString() {
+ return videoQualityString;
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param newlyLoadedQuality The current video quality string.
+ */
+ public static void setVideoQuality(String newlyLoadedQuality) {
+ if (newlyLoadedQuality == null) {
+ return;
+ }
+ try {
+ String splitVideoQuality;
+ if (newlyLoadedQuality.contains("p")) {
+ splitVideoQuality = newlyLoadedQuality.split("p")[0];
+ videoQuality = Integer.parseInt(splitVideoQuality);
+ videoQualityString = splitVideoQuality + "p";
+ } else if (newlyLoadedQuality.contains("s")) {
+ splitVideoQuality = newlyLoadedQuality.split("s")[0];
+ videoQuality = Integer.parseInt(splitVideoQuality);
+ videoQualityString = splitVideoQuality + "s";
+ } else {
+ videoQuality = DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY;
+ videoQualityString = DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY_STRING;
+ }
+ } catch (NumberFormatException ignored) {
+ }
+ }
+
+ /**
+ * @return available video quality.
+ */
+ public static int getAvailableVideoQuality(int preferredQuality) {
+ if (videoQualities != null) {
+ int qualityToUse = videoQualities.get(0); // first element is automatic mode
+ for (Integer quality : videoQualities) {
+ if (quality <= preferredQuality && qualityToUse < quality) {
+ qualityToUse = quality;
+ }
+ }
+ preferredQuality = qualityToUse;
+ }
+ return preferredQuality;
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param qualities Video qualities available, ordered from largest to smallest, with index 0 being the 'automatic' value of -2
+ */
+ public static void setVideoQualityList(Object[] qualities) {
+ try {
+ if (videoQualities == null || videoQualities.size() != qualities.length) {
+ videoQualities = new ArrayList<>(qualities.length);
+ for (Object streamQuality : qualities) {
+ for (Field field : streamQuality.getClass().getFields()) {
+ if (field.getType().isAssignableFrom(Integer.TYPE)
+ && field.getName().length() <= 2) {
+ videoQualities.add(field.getInt(streamQuality));
+ }
+ }
+ }
+ Logger.printDebug(() -> "videoQualities: " + videoQualities);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to set quality list", ex);
+ }
+ }
+
+ /**
+ * Length of the current video playing. Includes Shorts.
+ *
+ * @return The length of the video in milliseconds.
+ * If the video is not yet loaded, or if the video is playing in the background with no video visible,
+ * then this returns zero.
+ */
+ public static long getVideoLength() {
+ return videoLength;
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param length The length of the video in milliseconds.
+ */
+ public static void setVideoLength(final long length) {
+ if (videoLength != length) {
+ videoLength = length;
+ }
+ }
+
+ /**
+ * Playback time of the current video playing. Includes Shorts.
+ *
+ * Value will lag behind the actual playback time by a variable amount based on the playback speed.
+ *
+ * If playback speed is 2.0x, this value may be up to 2000ms behind the actual playback time.
+ * If playback speed is 1.0x, this value may be up to 1000ms behind the actual playback time.
+ * If playback speed is 0.5x, this value may be up to 500ms behind the actual playback time.
+ * Etc.
+ *
+ * @return The time of the video in milliseconds. -1 if not set yet.
+ */
+ public static long getVideoTime() {
+ return videoTime;
+ }
+
+ /**
+ * Injection point.
+ * Called on the main thread every 1000ms.
+ *
+ * @param currentPlaybackTime The current playback time of the video in milliseconds.
+ */
+ public static void setVideoTime(final long currentPlaybackTime) {
+ videoTime = currentPlaybackTime;
+ }
+
+ /**
+ * Overrides the current quality.
+ * Rest of the implementation added by patch.
+ */
+ public static void overrideVideoQuality(int qualityOverride) {
+ Logger.printDebug(() -> "Overriding video quality to: " + qualityOverride);
+ }
+
+ /**
+ * Overrides the current video time by seeking.
+ * Rest of the implementation added by patch.
+ */
+ public static boolean overrideVideoTime(final long seekTime) {
+ // These instructions are ignored by patch.
+ Logger.printDebug(() -> "Seeking to " + seekTime);
+ return false;
+ }
+
+ /**
+ * Overrides the current video time by seeking. (MDX player)
+ * Rest of the implementation added by patch.
+ */
+ public static boolean overrideMDXVideoTime(final long seekTime) {
+ // These instructions are ignored by patch.
+ Logger.printDebug(() -> "Seeking to " + seekTime);
+ return false;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/shared/VideoType.kt b/extensions/shared/src/main/java/app/revanced/extension/music/shared/VideoType.kt
new file mode 100644
index 0000000000..87711a27ea
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/shared/VideoType.kt
@@ -0,0 +1,63 @@
+package app.revanced.extension.music.shared
+
+import app.revanced.extension.shared.utils.Event
+import app.revanced.extension.shared.utils.Logger
+
+/**
+ * Music video type
+ */
+enum class VideoType {
+ MUSIC_VIDEO_TYPE_UNKNOWN,
+ MUSIC_VIDEO_TYPE_ATV,
+ MUSIC_VIDEO_TYPE_OMV,
+ MUSIC_VIDEO_TYPE_UGC,
+ MUSIC_VIDEO_TYPE_SHOULDER,
+ MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC,
+ MUSIC_VIDEO_TYPE_PRIVATELY_OWNED_TRACK,
+ MUSIC_VIDEO_TYPE_LIVE_STREAM,
+ MUSIC_VIDEO_TYPE_PODCAST_EPISODE;
+
+ companion object {
+
+ private val nameToVideoType = values().associateBy { it.name }
+
+ @JvmStatic
+ fun setFromString(enumName: String) {
+ val newType = nameToVideoType[enumName]
+ if (newType == null) {
+ Logger.printException { "Unknown VideoType encountered: $enumName" }
+ } else if (current != newType) {
+ Logger.printDebug { "VideoType changed to: $newType" }
+ current = newType
+ }
+ }
+
+ /**
+ * The current video type.
+ */
+ @JvmStatic
+ var current
+ get() = currentVideoType
+ private set(value) {
+ currentVideoType = value
+ onChange(currentVideoType)
+ }
+
+ @Volatile // value is read/write from different threads
+ private var currentVideoType = MUSIC_VIDEO_TYPE_UNKNOWN
+
+ /**
+ * player type change listener
+ */
+ @JvmStatic
+ val onChange = Event()
+ }
+
+ fun isMusicVideo(): Boolean {
+ return this == MUSIC_VIDEO_TYPE_OMV
+ }
+
+ fun isPodCast(): Boolean {
+ return this == MUSIC_VIDEO_TYPE_PODCAST_EPISODE
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/SegmentPlaybackController.java b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/SegmentPlaybackController.java
new file mode 100644
index 0000000000..948a8a92e2
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/SegmentPlaybackController.java
@@ -0,0 +1,472 @@
+package app.revanced.extension.music.sponsorblock;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.graphics.Canvas;
+import android.graphics.Rect;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.lang.reflect.Field;
+import java.util.Arrays;
+import java.util.Objects;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.music.shared.VideoInformation;
+import app.revanced.extension.music.sponsorblock.objects.CategoryBehaviour;
+import app.revanced.extension.music.sponsorblock.objects.SponsorSegment;
+import app.revanced.extension.music.sponsorblock.requests.SBRequester;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+/**
+ * Handles showing, scheduling, and skipping of all {@link SponsorSegment} for the current video.
+ *
+ * Class is not thread safe. All methods must be called on the main thread unless otherwise specified.
+ */
+@SuppressWarnings("unused")
+public class SegmentPlaybackController {
+ @Nullable
+ private static String currentVideoId;
+ @Nullable
+ private static SponsorSegment[] segments;
+ /**
+ * Currently playing (non-highlight) segment that user can manually skip.
+ */
+ @Nullable
+ private static SponsorSegment segmentCurrentlyPlaying;
+ /**
+ * Currently playing manual skip segment that is scheduled to hide.
+ * This will always be NULL or equal to {@link #segmentCurrentlyPlaying}.
+ */
+ @Nullable
+ private static SponsorSegment scheduledHideSegment;
+ /**
+ * Upcoming segment that is scheduled to either autoskip or show the manual skip button.
+ */
+ @Nullable
+ private static SponsorSegment scheduledUpcomingSegment;
+ /**
+ * System time (in milliseconds) of when to hide the skip button of {@link #segmentCurrentlyPlaying}.
+ * Value is zero if playback is not inside a segment ({@link #segmentCurrentlyPlaying} is null),
+ */
+ private static long skipSegmentButtonEndTime;
+
+ private static int sponsorBarAbsoluteLeft;
+ private static int sponsorAbsoluteBarRight;
+ private static int sponsorBarThickness = 7;
+ private static SponsorSegment lastSegmentSkipped;
+ private static long lastSegmentSkippedTime;
+ private static int toastNumberOfSegmentsSkipped;
+ @Nullable
+ private static SponsorSegment toastSegmentSkipped;
+
+ private static void setSegments(@NonNull SponsorSegment[] videoSegments) {
+ Arrays.sort(videoSegments);
+ segments = videoSegments;
+ }
+
+ /**
+ * Clears all downloaded data.
+ */
+ private static void clearData() {
+ SponsorBlockSettings.initialize();
+ currentVideoId = null;
+ segments = null;
+ segmentCurrentlyPlaying = null;
+ scheduledUpcomingSegment = null;
+ scheduledHideSegment = null;
+ skipSegmentButtonEndTime = 0;
+ toastSegmentSkipped = null;
+ toastNumberOfSegmentsSkipped = 0;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void setVideoId(@NonNull String videoId) {
+ try {
+ if (Objects.equals(currentVideoId, videoId)) {
+ return;
+ }
+ clearData();
+ if (!Settings.SB_ENABLED.get()) {
+ return;
+ }
+ if (Utils.isNetworkNotConnected()) {
+ Logger.printDebug(() -> "Network not connected, ignoring video");
+ return;
+ }
+
+ currentVideoId = videoId;
+ Logger.printDebug(() -> "setCurrentVideoId: " + videoId);
+
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ executeDownloadSegments(videoId);
+ } catch (Exception e) {
+ Logger.printException(() -> "Failed to download segments", e);
+ }
+ });
+ } catch (Exception ex) {
+ Logger.printException(() -> "setCurrentVideoId failure", ex);
+ }
+ }
+
+ /**
+ * Must be called off main thread
+ */
+ static void executeDownloadSegments(@NonNull String videoId) {
+ Objects.requireNonNull(videoId);
+ try {
+ SponsorSegment[] segments = SBRequester.getSegments(videoId);
+
+ Utils.runOnMainThread(() -> {
+ if (!videoId.equals(currentVideoId)) {
+ // user changed videos before get segments network call could complete
+ Logger.printDebug(() -> "Ignoring segments for prior video: " + videoId);
+ return;
+ }
+ setSegments(segments);
+
+ // check for any skips now, instead of waiting for the next update to setVideoTime()
+ setVideoTime(VideoInformation.getVideoTime());
+ });
+ } catch (Exception ex) {
+ Logger.printException(() -> "executeDownloadSegments failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ * Updates SponsorBlock every 1000ms.
+ * When changing videos, this is first called with value 0 and then the video is changed.
+ */
+ public static void setVideoTime(long millis) {
+ try {
+ if (!Settings.SB_ENABLED.get() || segments == null || segments.length == 0) {
+ return;
+ }
+ Logger.printDebug(() -> "setVideoTime: " + millis);
+
+ final float playbackSpeed = VideoInformation.getPlaybackSpeed();
+ // Amount of time to look ahead for the next segment,
+ // and the threshold to determine if a scheduled show/hide is at the correct video time when it's run.
+ //
+ // This value must be greater than largest time between calls to this method (1000ms),
+ // and must be adjusted for the video speed.
+ //
+ // To debug the stale skip logic, set this to a very large value (5000 or more)
+ // then try manually seeking just before playback reaches a segment skip.
+ final long speedAdjustedTimeThreshold = (long) (playbackSpeed * 1200);
+ final long startTimerLookAheadThreshold = millis + speedAdjustedTimeThreshold;
+
+ SponsorSegment foundSegmentCurrentlyPlaying = null;
+ SponsorSegment foundUpcomingSegment = null;
+
+ for (final SponsorSegment segment : segments) {
+ if (segment.category.behaviour == CategoryBehaviour.IGNORE) {
+ continue;
+ }
+ if (segment.end <= millis) {
+ continue; // past this segment
+ }
+
+ if (segment.start <= millis) {
+ // we are in the segment!
+ if (segment.shouldAutoSkip()) {
+ skipSegment(segment);
+ return; // must return, as skipping causes a recursive call back into this method
+ }
+
+ // first found segment, or it's an embedded segment and fully inside the outer segment
+ if (foundSegmentCurrentlyPlaying == null || foundSegmentCurrentlyPlaying.containsSegment(segment)) {
+ // If the found segment is not currently displayed, then do not show if the segment is nearly over.
+ // This check prevents the skip button text from rapidly changing when multiple segments end at nearly the same time.
+ // Also prevents showing the skip button if user seeks into the last 800ms of the segment.
+ final long minMillisOfSegmentRemainingThreshold = 800;
+ if (segmentCurrentlyPlaying == segment
+ || !segment.endIsNear(millis, minMillisOfSegmentRemainingThreshold)) {
+ foundSegmentCurrentlyPlaying = segment;
+ } else {
+ Logger.printDebug(() -> "Ignoring segment that ends very soon: " + segment);
+ }
+ }
+ // Keep iterating and looking. There may be an upcoming autoskip,
+ // or there may be another smaller segment nested inside this segment
+ continue;
+ }
+
+ // segment is upcoming
+ if (startTimerLookAheadThreshold < segment.start) {
+ break; // segment is not close enough to schedule, and no segments after this are of interest
+ }
+ if (segment.shouldAutoSkip()) { // upcoming autoskip
+ foundUpcomingSegment = segment;
+ break; // must stop here
+ }
+
+ // upcoming manual skip
+
+ // do not schedule upcoming segment, if it is not fully contained inside the current segment
+ if ((foundSegmentCurrentlyPlaying == null || foundSegmentCurrentlyPlaying.containsSegment(segment))
+ // use the most inner upcoming segment
+ && (foundUpcomingSegment == null || foundUpcomingSegment.containsSegment(segment))) {
+
+ // Only schedule, if the segment start time is not near the end time of the current segment.
+ // This check is needed to prevent scheduled hide and show from clashing with each other.
+ // Instead the upcoming segment will be handled when the current segment scheduled hide calls back into this method.
+ final long minTimeBetweenStartEndOfSegments = 1000;
+ if (foundSegmentCurrentlyPlaying == null
+ || !foundSegmentCurrentlyPlaying.endIsNear(segment.start, minTimeBetweenStartEndOfSegments)) {
+ foundUpcomingSegment = segment;
+ } else {
+ Logger.printDebug(() -> "Not scheduling segment (start time is near end of current segment): " + segment);
+ }
+ }
+ }
+
+ if (segmentCurrentlyPlaying != foundSegmentCurrentlyPlaying) {
+ setSegmentCurrentlyPlaying(foundSegmentCurrentlyPlaying);
+ } else if (foundSegmentCurrentlyPlaying != null
+ && skipSegmentButtonEndTime != 0 && skipSegmentButtonEndTime <= System.currentTimeMillis()) {
+ Logger.printDebug(() -> "Auto hiding skip button for segment: " + segmentCurrentlyPlaying);
+ skipSegmentButtonEndTime = 0;
+ }
+
+ // schedule a hide, only if the segment end is near
+ final SponsorSegment segmentToHide =
+ (foundSegmentCurrentlyPlaying != null && foundSegmentCurrentlyPlaying.endIsNear(millis, speedAdjustedTimeThreshold))
+ ? foundSegmentCurrentlyPlaying
+ : null;
+
+ if (scheduledHideSegment != segmentToHide) {
+ if (segmentToHide == null) {
+ Logger.printDebug(() -> "Clearing scheduled hide: " + scheduledHideSegment);
+ scheduledHideSegment = null;
+ } else {
+ scheduledHideSegment = segmentToHide;
+ Logger.printDebug(() -> "Scheduling hide segment: " + segmentToHide + " playbackSpeed: " + playbackSpeed);
+ final long delayUntilHide = (long) ((segmentToHide.end - millis) / playbackSpeed);
+ Utils.runOnMainThreadDelayed(() -> {
+ if (scheduledHideSegment != segmentToHide) {
+ Logger.printDebug(() -> "Ignoring old scheduled hide segment: " + segmentToHide);
+ return;
+ }
+ scheduledHideSegment = null;
+
+ final long videoTime = VideoInformation.getVideoTime();
+ if (!segmentToHide.endIsNear(videoTime, speedAdjustedTimeThreshold)) {
+ // current video time is not what's expected. User paused playback
+ Logger.printDebug(() -> "Ignoring outdated scheduled hide: " + segmentToHide
+ + " videoInformation time: " + videoTime);
+ return;
+ }
+ Logger.printDebug(() -> "Running scheduled hide segment: " + segmentToHide);
+ // Need more than just hide the skip button, as this may have been an embedded segment
+ // Instead call back into setVideoTime to check everything again.
+ // Should not use VideoInformation time as it is less accurate,
+ // but this scheduled handler was scheduled precisely so we can just use the segment end time
+ setSegmentCurrentlyPlaying(null);
+ setVideoTime(segmentToHide.end);
+ }, delayUntilHide);
+ }
+ }
+
+ if (scheduledUpcomingSegment != foundUpcomingSegment) {
+ if (foundUpcomingSegment == null) {
+ Logger.printDebug(() -> "Clearing scheduled segment: " + scheduledUpcomingSegment);
+ scheduledUpcomingSegment = null;
+ } else {
+ scheduledUpcomingSegment = foundUpcomingSegment;
+ final SponsorSegment segmentToSkip = foundUpcomingSegment;
+
+ Logger.printDebug(() -> "Scheduling segment: " + segmentToSkip + " playbackSpeed: " + playbackSpeed);
+ final long delayUntilSkip = (long) ((segmentToSkip.start - millis) / playbackSpeed);
+ Utils.runOnMainThreadDelayed(() -> {
+ if (scheduledUpcomingSegment != segmentToSkip) {
+ Logger.printDebug(() -> "Ignoring old scheduled segment: " + segmentToSkip);
+ return;
+ }
+ scheduledUpcomingSegment = null;
+
+ final long videoTime = VideoInformation.getVideoTime();
+ if (!segmentToSkip.startIsNear(videoTime, speedAdjustedTimeThreshold)) {
+ // current video time is not what's expected. User paused playback
+ Logger.printDebug(() -> "Ignoring outdated scheduled segment: " + segmentToSkip
+ + " videoInformation time: " + videoTime);
+ return;
+ }
+ if (segmentToSkip.shouldAutoSkip()) {
+ Logger.printDebug(() -> "Running scheduled skip segment: " + segmentToSkip);
+ skipSegment(segmentToSkip);
+ } else {
+ Logger.printDebug(() -> "Running scheduled show segment: " + segmentToSkip);
+ setSegmentCurrentlyPlaying(segmentToSkip);
+ }
+ }, delayUntilSkip);
+ }
+ }
+ } catch (Exception e) {
+ Logger.printException(() -> "setVideoTime failure", e);
+ }
+ }
+
+ private static void setSegmentCurrentlyPlaying(@Nullable SponsorSegment segment) {
+ if (segment == null) {
+ if (segmentCurrentlyPlaying != null)
+ Logger.printDebug(() -> "Hiding segment: " + segmentCurrentlyPlaying);
+ segmentCurrentlyPlaying = null;
+ skipSegmentButtonEndTime = 0;
+ return;
+ }
+ segmentCurrentlyPlaying = segment;
+ skipSegmentButtonEndTime = 0;
+ Logger.printDebug(() -> "Showing segment: " + segment);
+ }
+
+ private static void skipSegment(@NonNull SponsorSegment segmentToSkip) {
+ try {
+ // If trying to seek to end of the video, YouTube can seek just before of the actual end.
+ // (especially if the video does not end on a whole second boundary).
+ // This causes additional segment skip attempts, even though it cannot seek any closer to the desired time.
+ // Check for and ignore repeated skip attempts of the same segment over a small time period.
+ final long now = System.currentTimeMillis();
+ final long minimumMillisecondsBetweenSkippingSameSegment = 500;
+ if ((lastSegmentSkipped == segmentToSkip) && (now - lastSegmentSkippedTime < minimumMillisecondsBetweenSkippingSameSegment)) {
+ Logger.printDebug(() -> "Ignoring skip segment request (already skipped as close as possible): " + segmentToSkip);
+ return;
+ }
+
+ Logger.printDebug(() -> "Skipping segment: " + segmentToSkip);
+ lastSegmentSkipped = segmentToSkip;
+ lastSegmentSkippedTime = now;
+ setSegmentCurrentlyPlaying(null);
+ scheduledHideSegment = null;
+ scheduledUpcomingSegment = null;
+
+ // If the seek is successful, then the seek causes a recursive call back into this class.
+ final boolean seekSuccessful = VideoInformation.seekTo(segmentToSkip.end);
+ if (!seekSuccessful) {
+ // can happen when switching videos and is normal
+ Logger.printDebug(() -> "Could not skip segment (seek unsuccessful): " + segmentToSkip);
+ return;
+ }
+
+ // check for any smaller embedded segments, and count those as autoskipped
+ final boolean showSkipToast = Settings.SB_TOAST_ON_SKIP.get();
+ for (final SponsorSegment otherSegment : Objects.requireNonNull(segments)) {
+ if (segmentToSkip.end < otherSegment.start) {
+ break; // no other segments can be contained
+ }
+ if (otherSegment == segmentToSkip ||
+ segmentToSkip.containsSegment(otherSegment)) {
+ otherSegment.didAutoSkipped = true;
+ // Do not show a toast if the user is scrubbing thru a paused video.
+ // Cannot do this video state check in setTime or earlier in this method, as the video state may not be up to date.
+ // So instead, only hide toasts because all other skip logic done while paused causes no harm.
+ if (showSkipToast) {
+ showSkippedSegmentToast(otherSegment);
+ }
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "skipSegment failure", ex);
+ }
+ }
+
+ private static void showSkippedSegmentToast(@NonNull SponsorSegment segment) {
+ Utils.verifyOnMainThread();
+ toastNumberOfSegmentsSkipped++;
+ if (toastNumberOfSegmentsSkipped > 1) {
+ return; // toast already scheduled
+ }
+ toastSegmentSkipped = segment;
+
+ final long delayToToastMilliseconds = 250; // also the maximum time between skips to be considered skipping multiple segments
+ Utils.runOnMainThreadDelayed(() -> {
+ try {
+ if (toastSegmentSkipped == null) { // video was changed just after skipping segment
+ Logger.printDebug(() -> "Ignoring old scheduled show toast");
+ return;
+ }
+ Utils.showToastShort(toastNumberOfSegmentsSkipped == 1
+ ? toastSegmentSkipped.getSkippedToastText()
+ : str("revanced_sb_skipped_multiple_segments"));
+ } catch (Exception ex) {
+ Logger.printException(() -> "showSkippedSegmentToast failure", ex);
+ } finally {
+ toastNumberOfSegmentsSkipped = 0;
+ toastSegmentSkipped = null;
+ }
+ }, delayToToastMilliseconds);
+ }
+
+ /**
+ * Injection point
+ */
+ public static void setSponsorBarRect(final Object self, final String fieldName) {
+ try {
+ Field field = self.getClass().getDeclaredField(fieldName);
+ field.setAccessible(true);
+ Rect rect = (Rect) Objects.requireNonNull(field.get(self));
+ setSponsorBarAbsoluteLeft(rect);
+ setSponsorBarAbsoluteRight(rect);
+ } catch (Exception ex) {
+ Logger.printException(() -> "setSponsorBarRect failure", ex);
+ }
+ }
+
+ private static void setSponsorBarAbsoluteLeft(Rect rect) {
+ final int left = rect.left;
+ if (sponsorBarAbsoluteLeft != left) {
+ Logger.printDebug(() -> "setSponsorBarAbsoluteLeft: " + left);
+ sponsorBarAbsoluteLeft = left;
+ }
+ }
+
+ private static void setSponsorBarAbsoluteRight(Rect rect) {
+ final int right = rect.right;
+ if (sponsorAbsoluteBarRight != right) {
+ Logger.printDebug(() -> "setSponsorBarAbsoluteRight: " + right);
+ sponsorAbsoluteBarRight = right;
+ }
+ }
+
+ /**
+ * Injection point
+ */
+ public static void setSponsorBarThickness(int thickness) {
+ if (sponsorBarThickness != thickness) {
+ sponsorBarThickness = (int) Math.round(thickness * 1.2);
+ Logger.printDebug(() -> "setSponsorBarThickness: " + sponsorBarThickness);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void drawSponsorTimeBars(final Canvas canvas, final float posY) {
+ try {
+ if (segments == null) return;
+ final long videoLength = VideoInformation.getVideoLength();
+ if (videoLength <= 0) return;
+
+ final int thicknessDiv2 = sponsorBarThickness / 2; // rounds down
+ final float top = posY - (sponsorBarThickness - thicknessDiv2);
+ final float bottom = posY + thicknessDiv2;
+ final float videoMillisecondsToPixels = (1f / videoLength) * (sponsorAbsoluteBarRight - sponsorBarAbsoluteLeft);
+ final float leftPadding = sponsorBarAbsoluteLeft;
+
+ for (SponsorSegment segment : segments) {
+ final float left = leftPadding + segment.start * videoMillisecondsToPixels;
+ final float right = leftPadding + segment.end * videoMillisecondsToPixels;
+ canvas.drawRect(left, top, right, bottom, segment.category.paint);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "drawSponsorTimeBars failure", ex);
+ }
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/SponsorBlockSettings.java b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/SponsorBlockSettings.java
new file mode 100644
index 0000000000..813e0d0f9d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/SponsorBlockSettings.java
@@ -0,0 +1,52 @@
+package app.revanced.extension.music.sponsorblock;
+
+import androidx.annotation.NonNull;
+
+import java.util.UUID;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.music.sponsorblock.objects.SegmentCategory;
+import app.revanced.extension.shared.settings.Setting;
+
+public class SponsorBlockSettings {
+ private static boolean initialized;
+
+ /**
+ * @return if the user has ever voted, created a segment, or imported existing SB settings.
+ */
+ public static boolean userHasSBPrivateId() {
+ return !Settings.SB_PRIVATE_USER_ID.get().isEmpty();
+ }
+
+ /**
+ * Use this only if a user id is required (creating segments, voting).
+ */
+ @NonNull
+ public static String getSBPrivateUserID() {
+ String uuid = Settings.SB_PRIVATE_USER_ID.get();
+ if (uuid.isEmpty()) {
+ uuid = (UUID.randomUUID().toString() +
+ UUID.randomUUID().toString() +
+ UUID.randomUUID().toString())
+ .replace("-", "");
+ Settings.SB_PRIVATE_USER_ID.save(uuid);
+ }
+ return uuid;
+ }
+
+ public static void initialize() {
+ if (initialized) {
+ return;
+ }
+ initialized = true;
+
+ SegmentCategory.updateEnabledCategories();
+ }
+
+ /**
+ * Updates internal data based on {@link Setting} values.
+ */
+ public static void updateFromImportedSettings() {
+ SegmentCategory.loadAllCategoriesFromSettings();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/CategoryBehaviour.java b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/CategoryBehaviour.java
new file mode 100644
index 0000000000..bba2334dcc
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/CategoryBehaviour.java
@@ -0,0 +1,49 @@
+package app.revanced.extension.music.sponsorblock.objects;
+
+import static app.revanced.extension.shared.utils.StringRef.sf;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Objects;
+
+import app.revanced.extension.shared.utils.StringRef;
+
+public enum CategoryBehaviour {
+ SKIP_AUTOMATICALLY("skip", 2, true, sf("revanced_sb_skip_automatically")),
+ // ignored categories are not exported to json, and ignore is the default behavior when importing
+ IGNORE("ignore", -1, false, sf("revanced_sb_skip_ignore"));
+
+ /**
+ * ReVanced specific value.
+ */
+ @NonNull
+ public final String reVancedKeyValue;
+ /**
+ * Desktop specific value.
+ */
+ public final int desktopKeyValue;
+ /**
+ * If the segment should skip automatically
+ */
+ public final boolean skipAutomatically;
+ @NonNull
+ public final StringRef description;
+
+ CategoryBehaviour(String reVancedKeyValue, int desktopKeyValue, boolean skipAutomatically, StringRef description) {
+ this.reVancedKeyValue = Objects.requireNonNull(reVancedKeyValue);
+ this.desktopKeyValue = desktopKeyValue;
+ this.skipAutomatically = skipAutomatically;
+ this.description = Objects.requireNonNull(description);
+ }
+
+ @Nullable
+ public static CategoryBehaviour byReVancedKeyValue(@NonNull String keyValue) {
+ for (CategoryBehaviour behaviour : values()) {
+ if (behaviour.reVancedKeyValue.equals(keyValue)) {
+ return behaviour;
+ }
+ }
+ return null;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/SegmentCategory.java b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/SegmentCategory.java
new file mode 100644
index 0000000000..d20827e6f3
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/SegmentCategory.java
@@ -0,0 +1,293 @@
+package app.revanced.extension.music.sponsorblock.objects;
+
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_FILLER;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_FILLER_COLOR;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_INTERACTION;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_INTERACTION_COLOR;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_INTRO;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_INTRO_COLOR;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_MUSIC_OFFTOPIC;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_MUSIC_OFFTOPIC_COLOR;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_OUTRO;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_OUTRO_COLOR;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_PREVIEW;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_PREVIEW_COLOR;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_SELF_PROMO;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_SELF_PROMO_COLOR;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_SPONSOR;
+import static app.revanced.extension.music.settings.Settings.SB_CATEGORY_SPONSOR_COLOR;
+import static app.revanced.extension.shared.utils.StringRef.sf;
+
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.text.Html;
+import android.text.Spanned;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import app.revanced.extension.shared.settings.StringSetting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.StringRef;
+import app.revanced.extension.shared.utils.Utils;
+
+public enum SegmentCategory {
+ SPONSOR("sponsor", sf("revanced_sb_segments_sponsor"), sf("revanced_sb_segments_sponsor_sum"), sf("revanced_sb_skip_button_sponsor"), sf("revanced_sb_skipped_sponsor"),
+ SB_CATEGORY_SPONSOR, SB_CATEGORY_SPONSOR_COLOR),
+ SELF_PROMO("selfpromo", sf("revanced_sb_segments_selfpromo"), sf("revanced_sb_segments_selfpromo_sum"), sf("revanced_sb_skip_button_selfpromo"), sf("revanced_sb_skipped_selfpromo"),
+ SB_CATEGORY_SELF_PROMO, SB_CATEGORY_SELF_PROMO_COLOR),
+ INTERACTION("interaction", sf("revanced_sb_segments_interaction"), sf("revanced_sb_segments_interaction_sum"), sf("revanced_sb_skip_button_interaction"), sf("revanced_sb_skipped_interaction"),
+ SB_CATEGORY_INTERACTION, SB_CATEGORY_INTERACTION_COLOR),
+ INTRO("intro", sf("revanced_sb_segments_intro"), sf("revanced_sb_segments_intro_sum"),
+ sf("revanced_sb_skip_button_intro_beginning"), sf("revanced_sb_skip_button_intro_middle"), sf("revanced_sb_skip_button_intro_end"),
+ sf("revanced_sb_skipped_intro_beginning"), sf("revanced_sb_skipped_intro_middle"), sf("revanced_sb_skipped_intro_end"),
+ SB_CATEGORY_INTRO, SB_CATEGORY_INTRO_COLOR),
+ OUTRO("outro", sf("revanced_sb_segments_outro"), sf("revanced_sb_segments_outro_sum"), sf("revanced_sb_skip_button_outro"), sf("revanced_sb_skipped_outro"),
+ SB_CATEGORY_OUTRO, SB_CATEGORY_OUTRO_COLOR),
+ PREVIEW("preview", sf("revanced_sb_segments_preview"), sf("revanced_sb_segments_preview_sum"),
+ sf("revanced_sb_skip_button_preview_beginning"), sf("revanced_sb_skip_button_preview_middle"), sf("revanced_sb_skip_button_preview_end"),
+ sf("revanced_sb_skipped_preview_beginning"), sf("revanced_sb_skipped_preview_middle"), sf("revanced_sb_skipped_preview_end"),
+ SB_CATEGORY_PREVIEW, SB_CATEGORY_PREVIEW_COLOR),
+ FILLER("filler", sf("revanced_sb_segments_filler"), sf("revanced_sb_segments_filler_sum"), sf("revanced_sb_skip_button_filler"), sf("revanced_sb_skipped_filler"),
+ SB_CATEGORY_FILLER, SB_CATEGORY_FILLER_COLOR),
+ MUSIC_OFFTOPIC("music_offtopic", sf("revanced_sb_segments_nomusic"), sf("revanced_sb_segments_nomusic_sum"), sf("revanced_sb_skip_button_nomusic"), sf("revanced_sb_skipped_nomusic"),
+ SB_CATEGORY_MUSIC_OFFTOPIC, SB_CATEGORY_MUSIC_OFFTOPIC_COLOR);
+
+ private static final SegmentCategory[] categoriesWithoutUnsubmitted = new SegmentCategory[]{
+ SPONSOR,
+ SELF_PROMO,
+ INTERACTION,
+ INTRO,
+ OUTRO,
+ PREVIEW,
+ FILLER,
+ MUSIC_OFFTOPIC,
+ };
+ private static final Map mValuesMap = new HashMap<>(2 * categoriesWithoutUnsubmitted.length);
+
+ /**
+ * Categories currently enabled, formatted for an API call
+ */
+ public static String sponsorBlockAPIFetchCategories = "[]";
+
+ static {
+ for (SegmentCategory value : categoriesWithoutUnsubmitted)
+ mValuesMap.put(value.keyValue, value);
+ }
+
+ @NonNull
+ public static SegmentCategory[] categoriesWithoutUnsubmitted() {
+ return categoriesWithoutUnsubmitted;
+ }
+
+ @Nullable
+ public static SegmentCategory byCategoryKey(@NonNull String key) {
+ return mValuesMap.get(key);
+ }
+
+ /**
+ * Must be called if behavior of any category is changed
+ */
+ public static void updateEnabledCategories() {
+ Utils.verifyOnMainThread();
+ Logger.printDebug(() -> "updateEnabledCategories");
+ SegmentCategory[] categories = categoriesWithoutUnsubmitted();
+ List enabledCategories = new ArrayList<>(categories.length);
+ for (SegmentCategory category : categories) {
+ if (category.behaviour != CategoryBehaviour.IGNORE) {
+ enabledCategories.add(category.keyValue);
+ }
+ }
+
+ //"[%22sponsor%22,%22outro%22,%22music_offtopic%22,%22intro%22,%22selfpromo%22,%22interaction%22,%22preview%22]";
+ if (enabledCategories.isEmpty())
+ sponsorBlockAPIFetchCategories = "[]";
+ else
+ sponsorBlockAPIFetchCategories = "[%22" + TextUtils.join("%22,%22", enabledCategories) + "%22]";
+ }
+
+ public static void loadAllCategoriesFromSettings() {
+ for (SegmentCategory category : values()) {
+ category.loadFromSettings();
+ }
+ updateEnabledCategories();
+ }
+
+ @NonNull
+ public final String keyValue;
+ @NonNull
+ private final StringSetting behaviorSetting;
+ @NonNull
+ private final StringSetting colorSetting;
+
+ @NonNull
+ public final StringRef title;
+ @NonNull
+ public final StringRef description;
+
+ /**
+ * Skip button text, if the skip occurs in the first quarter of the video
+ */
+ @NonNull
+ public final StringRef skipButtonTextBeginning;
+ /**
+ * Skip button text, if the skip occurs in the middle half of the video
+ */
+ @NonNull
+ public final StringRef skipButtonTextMiddle;
+ /**
+ * Skip button text, if the skip occurs in the last quarter of the video
+ */
+ @NonNull
+ public final StringRef skipButtonTextEnd;
+ /**
+ * Skipped segment toast, if the skip occurred in the first quarter of the video
+ */
+ @NonNull
+ public final StringRef skippedToastBeginning;
+ /**
+ * Skipped segment toast, if the skip occurred in the middle half of the video
+ */
+ @NonNull
+ public final StringRef skippedToastMiddle;
+ /**
+ * Skipped segment toast, if the skip occurred in the last quarter of the video
+ */
+ @NonNull
+ public final StringRef skippedToastEnd;
+
+ @NonNull
+ public final Paint paint;
+
+ /**
+ * Value must be changed using {@link #setColor(String)}.
+ */
+ public int color;
+
+ /**
+ * Value must be changed using {@link #setBehaviour(CategoryBehaviour)}.
+ * Caller must also {@link #updateEnabledCategories()}.
+ */
+ @NonNull
+ public CategoryBehaviour behaviour = CategoryBehaviour.SKIP_AUTOMATICALLY;
+
+ SegmentCategory(String keyValue, StringRef title, StringRef description,
+ StringRef skipButtonText,
+ StringRef skippedToastText,
+ StringSetting behavior, StringSetting color) {
+ this(keyValue, title, description,
+ skipButtonText, skipButtonText, skipButtonText,
+ skippedToastText, skippedToastText, skippedToastText,
+ behavior, color);
+ }
+
+ SegmentCategory(String keyValue, StringRef title, StringRef description,
+ StringRef skipButtonTextBeginning, StringRef skipButtonTextMiddle, StringRef skipButtonTextEnd,
+ StringRef skippedToastBeginning, StringRef skippedToastMiddle, StringRef skippedToastEnd,
+ StringSetting behavior, StringSetting color) {
+ this.keyValue = Objects.requireNonNull(keyValue);
+ this.title = Objects.requireNonNull(title);
+ this.description = Objects.requireNonNull(description);
+ this.skipButtonTextBeginning = Objects.requireNonNull(skipButtonTextBeginning);
+ this.skipButtonTextMiddle = Objects.requireNonNull(skipButtonTextMiddle);
+ this.skipButtonTextEnd = Objects.requireNonNull(skipButtonTextEnd);
+ this.skippedToastBeginning = Objects.requireNonNull(skippedToastBeginning);
+ this.skippedToastMiddle = Objects.requireNonNull(skippedToastMiddle);
+ this.skippedToastEnd = Objects.requireNonNull(skippedToastEnd);
+ this.behaviorSetting = Objects.requireNonNull(behavior);
+ this.colorSetting = Objects.requireNonNull(color);
+ this.paint = new Paint();
+ loadFromSettings();
+ }
+
+ private void loadFromSettings() {
+ String behaviorString = behaviorSetting.get();
+ CategoryBehaviour savedBehavior = CategoryBehaviour.byReVancedKeyValue(behaviorString);
+ if (savedBehavior == null) {
+ Logger.printException(() -> "Invalid behavior: " + behaviorString);
+ behaviorSetting.resetToDefault();
+ loadFromSettings();
+ return;
+ }
+ this.behaviour = savedBehavior;
+
+ String colorString = colorSetting.get();
+ try {
+ setColor(colorString);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Invalid color: " + colorString, ex);
+ colorSetting.resetToDefault();
+ loadFromSettings();
+ }
+ }
+
+ public void setBehaviour(@NonNull CategoryBehaviour behaviour) {
+ this.behaviour = Objects.requireNonNull(behaviour);
+ this.behaviorSetting.save(behaviour.reVancedKeyValue);
+ }
+
+ /**
+ * @return HTML color format string
+ */
+ @NonNull
+ public String colorString() {
+ return String.format("#%06X", color);
+ }
+
+ public void setColor(@NonNull String colorString) throws IllegalArgumentException {
+ final int color = Color.parseColor(colorString) & 0xFFFFFF;
+ this.color = color;
+ paint.setColor(color);
+ paint.setAlpha(255);
+ colorSetting.save(colorString); // Save after parsing.
+ }
+
+ public void resetColor() {
+ setColor(colorSetting.defaultValue);
+ }
+
+ @NonNull
+ private static String getCategoryColorDotHTML(int color) {
+ color &= 0xFFFFFF;
+ return String.format("⬤ ", color);
+ }
+
+ /**
+ * @noinspection deprecation
+ */
+ @NonNull
+ public static Spanned getCategoryColorDot(int color) {
+ return Html.fromHtml(getCategoryColorDotHTML(color));
+ }
+
+ @NonNull
+ public Spanned getCategoryColorDot() {
+ return getCategoryColorDot(color);
+ }
+
+ /**
+ * @param segmentStartTime video time the segment category started
+ * @param videoLength length of the video
+ * @return 'skipped segment' toast message
+ */
+ @NonNull
+ StringRef getSkippedToastText(long segmentStartTime, long videoLength) {
+ if (videoLength == 0) {
+ return skippedToastBeginning; // video is still loading. Assume it's the beginning
+ }
+ final float position = segmentStartTime / (float) videoLength;
+ if (position < 0.25f) {
+ return skippedToastBeginning;
+ } else if (position < 0.75f) {
+ return skippedToastMiddle;
+ }
+ return skippedToastEnd;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/SponsorSegment.java b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/SponsorSegment.java
new file mode 100644
index 0000000000..85c2e0c267
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/objects/SponsorSegment.java
@@ -0,0 +1,102 @@
+package app.revanced.extension.music.sponsorblock.objects;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Objects;
+
+import app.revanced.extension.music.shared.VideoInformation;
+
+public class SponsorSegment implements Comparable {
+ @NonNull
+ public final SegmentCategory category;
+ /**
+ * NULL if segment is unsubmitted
+ */
+ @Nullable
+ public final String UUID;
+ public final long start;
+ public final long end;
+ public final boolean isLocked;
+ public boolean didAutoSkipped = false;
+
+ public SponsorSegment(@NonNull SegmentCategory category, @Nullable String UUID, long start, long end, boolean isLocked) {
+ this.category = category;
+ this.UUID = UUID;
+ this.start = start;
+ this.end = end;
+ this.isLocked = isLocked;
+ }
+
+ public boolean shouldAutoSkip() {
+ return category.behaviour.skipAutomatically;
+ }
+
+ /**
+ * @param nearThreshold threshold to declare the time parameter is near this segment. Must be a positive number
+ */
+ public boolean startIsNear(long videoTime, long nearThreshold) {
+ return Math.abs(start - videoTime) <= nearThreshold;
+ }
+
+ /**
+ * @param nearThreshold threshold to declare the time parameter is near this segment. Must be a positive number
+ */
+ public boolean endIsNear(long videoTime, long nearThreshold) {
+ return Math.abs(end - videoTime) <= nearThreshold;
+ }
+
+ /**
+ * @return if the segment is completely contained inside this segment
+ */
+ public boolean containsSegment(SponsorSegment other) {
+ return start <= other.start && other.end <= end;
+ }
+
+ /**
+ * @return the length of this segment, in milliseconds. Always a positive number.
+ */
+ public long length() {
+ return end - start;
+ }
+
+ /**
+ * @return 'skipped segment' toast message
+ */
+ @NonNull
+ public String getSkippedToastText() {
+ return category.getSkippedToastText(start, VideoInformation.getVideoLength()).toString();
+ }
+
+ @Override
+ public int compareTo(SponsorSegment o) {
+ // If both segments start at the same time, then sort with the longer segment first.
+ // This keeps the seekbar drawing correct since it draws the segments using the sorted order.
+ return start == o.start ? Long.compare(o.length(), length()) : Long.compare(start, o.start);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof SponsorSegment other)) return false;
+ return Objects.equals(UUID, other.UUID)
+ && category == other.category
+ && start == other.start
+ && end == other.end;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(UUID);
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "SponsorSegment{"
+ + "category=" + category
+ + ", start=" + start
+ + ", end=" + end
+ + '}';
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/requests/SBRequester.java b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/requests/SBRequester.java
new file mode 100644
index 0000000000..0b520fbfc7
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/sponsorblock/requests/SBRequester.java
@@ -0,0 +1,145 @@
+package app.revanced.extension.music.sponsorblock.requests;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.music.sponsorblock.SponsorBlockSettings;
+import app.revanced.extension.music.sponsorblock.objects.SegmentCategory;
+import app.revanced.extension.music.sponsorblock.objects.SponsorSegment;
+import app.revanced.extension.shared.requests.Requester;
+import app.revanced.extension.shared.requests.Route;
+import app.revanced.extension.shared.sponsorblock.requests.SBRoutes;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+public class SBRequester {
+ /**
+ * TCP timeout
+ */
+ private static final int TIMEOUT_TCP_DEFAULT_MILLISECONDS = 7000;
+
+ /**
+ * HTTP response timeout
+ */
+ private static final int TIMEOUT_HTTP_DEFAULT_MILLISECONDS = 10000;
+
+ /**
+ * Response code of a successful API call
+ */
+ private static final int HTTP_STATUS_CODE_SUCCESS = 200;
+
+ private SBRequester() {
+ }
+
+ private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex) {
+ if (Settings.SB_TOAST_ON_CONNECTION_ERROR.get()) {
+ Utils.showToastShort(toastMessage);
+ }
+ if (ex != null) {
+ Logger.printInfo(() -> toastMessage, ex);
+ }
+ }
+
+ @NonNull
+ public static SponsorSegment[] getSegments(@NonNull String videoId) {
+ Utils.verifyOffMainThread();
+ List segments = new ArrayList<>();
+ try {
+ HttpURLConnection connection = getConnectionFromRoute(SBRoutes.GET_SEGMENTS, videoId, SegmentCategory.sponsorBlockAPIFetchCategories);
+ final int responseCode = connection.getResponseCode();
+
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ JSONArray responseArray = Requester.parseJSONArray(connection);
+ final long minSegmentDuration = 0;
+ for (int i = 0, length = responseArray.length(); i < length; i++) {
+ JSONObject obj = (JSONObject) responseArray.get(i);
+ JSONArray segment = obj.getJSONArray("segment");
+ final long start = (long) (segment.getDouble(0) * 1000);
+ final long end = (long) (segment.getDouble(1) * 1000);
+
+ String uuid = obj.getString("UUID");
+ final boolean locked = obj.getInt("locked") == 1;
+ String categoryKey = obj.getString("category");
+ SegmentCategory category = SegmentCategory.byCategoryKey(categoryKey);
+ if (category == null) {
+ Logger.printException(() -> "Received unknown category: " + categoryKey); // should never happen
+ } else if ((end - start) >= minSegmentDuration) {
+ segments.add(new SponsorSegment(category, uuid, start, end, locked));
+ }
+ }
+ Logger.printDebug(() -> {
+ StringBuilder builder = new StringBuilder("Downloaded segments:");
+ for (SponsorSegment segment : segments) {
+ builder.append('\n').append(segment);
+ }
+ return builder.toString();
+ });
+ runVipCheckInBackgroundIfNeeded();
+ } else if (responseCode == 404) {
+ // no segments are found. a normal response
+ Logger.printDebug(() -> "No segments found for video: " + videoId);
+ } else {
+ handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_status", responseCode), null);
+ connection.disconnect(); // something went wrong, might as well disconnect
+ }
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_timeout"), ex);
+ } catch (IOException ex) {
+ handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_generic"), ex);
+ } catch (Exception ex) {
+ // Should never happen
+ Logger.printException(() -> "getSegments failure", ex);
+ }
+
+ return segments.toArray(new SponsorSegment[0]);
+ }
+
+ public static void runVipCheckInBackgroundIfNeeded() {
+ if (!SponsorBlockSettings.userHasSBPrivateId()) {
+ return; // User cannot be a VIP. User has never voted, created any segments, or has imported a SB user id.
+ }
+ long now = System.currentTimeMillis();
+ if (now < (Settings.SB_LAST_VIP_CHECK.get() + TimeUnit.DAYS.toMillis(3))) {
+ return;
+ }
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ JSONObject json = getJSONObject(SponsorBlockSettings.getSBPrivateUserID());
+ boolean vip = json.getBoolean("vip");
+ Settings.SB_USER_IS_VIP.save(vip);
+ Settings.SB_LAST_VIP_CHECK.save(now);
+ } catch (IOException ex) {
+ Logger.printInfo(() -> "Failed to check VIP (network error)", ex); // info, so no error toast is shown
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to check VIP", ex); // should never happen
+ }
+ });
+ }
+
+ // helpers
+
+ private static HttpURLConnection getConnectionFromRoute(@NonNull Route route, String... params) throws IOException {
+ HttpURLConnection connection = Requester.getConnectionFromRoute(Settings.SB_API_URL.get(), route, params);
+ connection.setConnectTimeout(TIMEOUT_TCP_DEFAULT_MILLISECONDS);
+ connection.setReadTimeout(TIMEOUT_HTTP_DEFAULT_MILLISECONDS);
+ return connection;
+ }
+
+ private static JSONObject getJSONObject(String... params) throws IOException, JSONException {
+ return Requester.parseJSONObject(getConnectionFromRoute(SBRoutes.IS_USER_VIP, params));
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/utils/ExtendedUtils.java b/extensions/shared/src/main/java/app/revanced/extension/music/utils/ExtendedUtils.java
new file mode 100644
index 0000000000..3a7243544f
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/utils/ExtendedUtils.java
@@ -0,0 +1,46 @@
+package app.revanced.extension.music.utils;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.util.TypedValue;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.shared.utils.PackageUtils;
+
+public class ExtendedUtils extends PackageUtils {
+
+ public static boolean isSpoofingToLessThan(@NonNull String versionName) {
+ if (!Settings.SPOOF_APP_VERSION.get())
+ return false;
+
+ return isVersionToLessThan(Settings.SPOOF_APP_VERSION_TARGET.get(), versionName);
+ }
+
+ private static int dpToPx(float dp) {
+ return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics());
+ }
+
+ @SuppressWarnings("deprecation")
+ public static AlertDialog.Builder getDialogBuilder(@NonNull Context context) {
+ return new AlertDialog.Builder(context, isSDKAbove(22)
+ ? android.R.style.Theme_DeviceDefault_Dialog_Alert
+ : AlertDialog.THEME_DEVICE_DEFAULT_DARK
+ );
+ }
+
+ public static FrameLayout.LayoutParams getLayoutParams() {
+ FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+
+ int left_margin = dpToPx(20);
+ int top_margin = dpToPx(10);
+ int right_margin = dpToPx(20);
+ int bottom_margin = dpToPx(4);
+ params.setMargins(left_margin, top_margin, right_margin, bottom_margin);
+
+ return params;
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/utils/RestartUtils.java b/extensions/shared/src/main/java/app/revanced/extension/music/utils/RestartUtils.java
new file mode 100644
index 0000000000..a4ca376418
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/utils/RestartUtils.java
@@ -0,0 +1,36 @@
+package app.revanced.extension.music.utils;
+
+import static app.revanced.extension.music.utils.ExtendedUtils.getDialogBuilder;
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.shared.utils.Utils.runOnMainThreadDelayed;
+
+import android.app.Activity;
+import android.content.Intent;
+
+import androidx.annotation.NonNull;
+
+import java.util.Objects;
+
+public class RestartUtils {
+
+ public static void restartApp(@NonNull Activity activity) {
+ final Intent intent = Objects.requireNonNull(activity.getPackageManager().getLaunchIntentForPackage(activity.getPackageName()));
+ final Intent mainIntent = Intent.makeRestartActivityTask(intent.getComponent());
+
+ activity.finishAffinity();
+ activity.startActivity(mainIntent);
+ Runtime.getRuntime().exit(0);
+ }
+
+ public static void showRestartDialog(@NonNull Activity activity) {
+ showRestartDialog(activity, "revanced_extended_restart_message", 0);
+ }
+
+ public static void showRestartDialog(@NonNull Activity activity, @NonNull String message, long delay) {
+ getDialogBuilder(activity)
+ .setMessage(str(message))
+ .setPositiveButton(android.R.string.ok, (dialog, id) -> runOnMainThreadDelayed(() -> restartApp(activity), delay))
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/music/utils/VideoUtils.java b/extensions/shared/src/main/java/app/revanced/extension/music/utils/VideoUtils.java
new file mode 100644
index 0000000000..059c311bd9
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/music/utils/VideoUtils.java
@@ -0,0 +1,87 @@
+package app.revanced.extension.music.utils;
+
+import static app.revanced.extension.music.settings.preference.ExternalDownloaderPreference.checkPackageIsEnabled;
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.media.AudioManager;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.music.settings.Settings;
+import app.revanced.extension.music.shared.VideoInformation;
+import app.revanced.extension.shared.settings.StringSetting;
+import app.revanced.extension.shared.utils.IntentUtils;
+import app.revanced.extension.shared.utils.Logger;
+
+@SuppressWarnings("unused")
+public class VideoUtils extends IntentUtils {
+ private static final StringSetting externalDownloaderPackageName =
+ Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME;
+
+ public static void launchExternalDownloader() {
+ launchExternalDownloader(VideoInformation.getVideoId());
+ }
+
+ public static void launchExternalDownloader(@NonNull String videoId) {
+ try {
+ String downloaderPackageName = externalDownloaderPackageName.get().trim();
+
+ if (downloaderPackageName.isEmpty()) {
+ externalDownloaderPackageName.resetToDefault();
+ downloaderPackageName = externalDownloaderPackageName.defaultValue;
+ }
+
+ if (!checkPackageIsEnabled()) {
+ return;
+ }
+
+ final String content = String.format("https://music.youtube.com/watch?v=%s", videoId);
+ launchExternalDownloader(content, downloaderPackageName);
+ } catch (Exception ex) {
+ Logger.printException(() -> "launchExternalDownloader failure", ex);
+ }
+ }
+
+ @SuppressLint("IntentReset")
+ public static void openInYouTube() {
+ final String videoId = VideoInformation.getVideoId();
+ if (videoId.isEmpty()) {
+ showToastShort(str("revanced_replace_flyout_menu_dismiss_queue_watch_on_youtube_warning"));
+ return;
+ }
+
+ if (context.getApplicationContext().getSystemService(Context.AUDIO_SERVICE) instanceof AudioManager audioManager) {
+ audioManager.requestAudioFocus(null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN);
+ }
+
+ String url = String.format("vnd.youtube://%s", videoId);
+ if (Settings.REPLACE_FLYOUT_MENU_DISMISS_QUEUE_CONTINUE_WATCH.get()) {
+ long seconds = VideoInformation.getVideoTime() / 1000;
+ url += String.format("?t=%s", seconds);
+ }
+
+ launchView(url);
+ }
+
+ public static void openInYouTubeMusic(@NonNull String songId) {
+ final String url = String.format("vnd.youtube.music://%s", songId);
+ launchView(url, context.getPackageName());
+ }
+
+ /**
+ * Rest of the implementation added by patch.
+ */
+ public static void shuffleTracks() {
+ Log.d("Extended: VideoUtils", "Tracks are shuffled");
+ }
+
+ /**
+ * Rest of the implementation added by patch.
+ */
+ public static void showPlaybackSpeedFlyoutMenu() {
+ Logger.printDebug(() -> "Playback speed flyout menu opened");
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/GeneralAdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/GeneralAdsPatch.java
new file mode 100644
index 0000000000..f108a49d74
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/GeneralAdsPatch.java
@@ -0,0 +1,42 @@
+package app.revanced.extension.reddit.patches;
+
+import com.reddit.domain.model.ILink;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import app.revanced.extension.reddit.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class GeneralAdsPatch {
+
+ private static List> filterChildren(final Iterable> links) {
+ final List filteredList = new ArrayList<>();
+
+ for (Object item : links) {
+ if (item instanceof ILink iLink && iLink.getPromoted()) continue;
+
+ filteredList.add(item);
+ }
+
+ return filteredList;
+ }
+
+ public static boolean hideCommentAds() {
+ return Settings.HIDE_COMMENT_ADS.get();
+ }
+
+ public static List> hideOldPostAds(List> list) {
+ if (!Settings.HIDE_OLD_POST_ADS.get())
+ return list;
+
+ return filterChildren(list);
+ }
+
+ public static void hideNewPostAds(ArrayList arrayList, Object object) {
+ if (Settings.HIDE_NEW_POST_ADS.get())
+ return;
+
+ arrayList.add(object);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/NavigationButtonsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/NavigationButtonsPatch.java
new file mode 100644
index 0000000000..301616c3e7
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/NavigationButtonsPatch.java
@@ -0,0 +1,53 @@
+package app.revanced.extension.reddit.patches;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+import java.util.List;
+
+import app.revanced.extension.reddit.settings.Settings;
+import app.revanced.extension.shared.utils.Logger;
+
+@SuppressWarnings("unused")
+public final class NavigationButtonsPatch {
+
+ public static List> hideNavigationButtons(List> list) {
+ try {
+ for (NavigationButton button : NavigationButton.values()) {
+ if (button.enabled && list.size() > button.index) {
+ list.remove(button.index);
+ }
+ }
+ } catch (Exception exception) {
+ Logger.printException(() -> "Failed to remove button list", exception);
+ }
+ return list;
+ }
+
+ public static void hideNavigationButtons(ViewGroup viewGroup) {
+ try {
+ if (viewGroup == null) return;
+ for (NavigationButton button : NavigationButton.values()) {
+ if (button.enabled && viewGroup.getChildCount() > button.index) {
+ View view = viewGroup.getChildAt(button.index);
+ if (view != null) view.setVisibility(View.GONE);
+ }
+ }
+ } catch (Exception exception) {
+ Logger.printException(() -> "Failed to remove button view", exception);
+ }
+ }
+
+ private enum NavigationButton {
+ CHAT(Settings.HIDE_CHAT_BUTTON.get(), 3),
+ CREATE(Settings.HIDE_CREATE_BUTTON.get(), 2),
+ DISCOVER(Settings.HIDE_DISCOVER_BUTTON.get(), 1);
+ private final boolean enabled;
+ private final int index;
+
+ NavigationButton(final boolean enabled, final int index) {
+ this.enabled = enabled;
+ this.index = index;
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/OpenLinksDirectlyPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/OpenLinksDirectlyPatch.java
new file mode 100644
index 0000000000..caab44f0e8
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/OpenLinksDirectlyPatch.java
@@ -0,0 +1,30 @@
+package app.revanced.extension.reddit.patches;
+
+import android.net.Uri;
+
+import app.revanced.extension.reddit.settings.Settings;
+import app.revanced.extension.shared.utils.Logger;
+
+@SuppressWarnings("unused")
+public final class OpenLinksDirectlyPatch {
+
+ /**
+ * Parses the given Reddit redirect uri by extracting the redirect query.
+ *
+ * @param uri The Reddit redirect uri.
+ * @return The redirect query.
+ */
+ public static Uri parseRedirectUri(Uri uri) {
+ try {
+ if (Settings.OPEN_LINKS_DIRECTLY.get()) {
+ final String parsedUri = uri.getQueryParameter("url");
+ if (parsedUri != null && !parsedUri.isEmpty())
+ return Uri.parse(parsedUri);
+ }
+ } catch (Exception e) {
+ Logger.printException(() -> "Can not parse URL: " + uri, e);
+ }
+ return uri;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/OpenLinksExternallyPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/OpenLinksExternallyPatch.java
new file mode 100644
index 0000000000..387f120ad7
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/OpenLinksExternallyPatch.java
@@ -0,0 +1,33 @@
+package app.revanced.extension.reddit.patches;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+
+import app.revanced.extension.reddit.settings.Settings;
+import app.revanced.extension.shared.utils.Logger;
+
+@SuppressWarnings("unused")
+public class OpenLinksExternallyPatch {
+
+ /**
+ * Override 'CustomTabsIntent', in order to open links in the default browser.
+ * Instead of doing CustomTabsActivity,
+ *
+ * @param activity The activity, to start an Intent.
+ * @param uri The URL to be opened in the default browser.
+ */
+ public static boolean openLinksExternally(Activity activity, Uri uri) {
+ try {
+ if (activity != null && uri != null && Settings.OPEN_LINKS_EXTERNALLY.get()) {
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(uri);
+ activity.startActivity(intent);
+ return true;
+ }
+ } catch (Exception e) {
+ Logger.printException(() -> "Can not open URL: " + uri, e);
+ }
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RecentlyVisitedShelfPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RecentlyVisitedShelfPatch.java
new file mode 100644
index 0000000000..5363688dfb
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RecentlyVisitedShelfPatch.java
@@ -0,0 +1,14 @@
+package app.revanced.extension.reddit.patches;
+
+import java.util.Collections;
+import java.util.List;
+
+import app.revanced.extension.reddit.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class RecentlyVisitedShelfPatch {
+
+ public static List> hideRecentlyVisitedShelf(List> list) {
+ return Settings.HIDE_RECENTLY_VISITED_SHELF.get() ? Collections.emptyList() : list;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RecommendedCommunitiesPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RecommendedCommunitiesPatch.java
new file mode 100644
index 0000000000..126d79761e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RecommendedCommunitiesPatch.java
@@ -0,0 +1,12 @@
+package app.revanced.extension.reddit.patches;
+
+import app.revanced.extension.reddit.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class RecommendedCommunitiesPatch {
+
+ public static boolean hideRecommendedCommunitiesShelf() {
+ return Settings.HIDE_RECOMMENDED_COMMUNITIES_SHELF.get();
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RemoveSubRedditDialogPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RemoveSubRedditDialogPatch.java
new file mode 100644
index 0000000000..98dd6c53b5
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/RemoveSubRedditDialogPatch.java
@@ -0,0 +1,41 @@
+package app.revanced.extension.reddit.patches;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.view.View;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.reddit.settings.Settings;
+import app.revanced.extension.shared.utils.Utils;
+
+@SuppressWarnings("unused")
+public class RemoveSubRedditDialogPatch {
+
+ public static void confirmDialog(@NonNull TextView textView) {
+ if (!Settings.REMOVE_NSFW_DIALOG.get())
+ return;
+
+ if (!textView.getText().toString().equals(str("nsfw_continue_non_anonymously")))
+ return;
+
+ clickViewDelayed(textView);
+ }
+
+ public static void dismissDialog(View cancelButtonView) {
+ if (!Settings.REMOVE_NOTIFICATION_DIALOG.get())
+ return;
+
+ clickViewDelayed(cancelButtonView);
+ }
+
+ private static void clickViewDelayed(View view) {
+ Utils.runOnMainThreadDelayed(() -> {
+ if (view != null) {
+ view.setSoundEffectsEnabled(false);
+ view.performClick();
+ }
+ }, 0);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/SanitizeUrlQueryPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/SanitizeUrlQueryPatch.java
new file mode 100644
index 0000000000..f19398376a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/SanitizeUrlQueryPatch.java
@@ -0,0 +1,12 @@
+package app.revanced.extension.reddit.patches;
+
+import app.revanced.extension.reddit.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class SanitizeUrlQueryPatch {
+
+ public static boolean stripQueryParameters() {
+ return Settings.SANITIZE_URL_QUERY.get();
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/ScreenshotPopupPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/ScreenshotPopupPatch.java
new file mode 100644
index 0000000000..7216ea55c4
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/ScreenshotPopupPatch.java
@@ -0,0 +1,11 @@
+package app.revanced.extension.reddit.patches;
+
+import app.revanced.extension.reddit.settings.Settings;
+
+@SuppressWarnings("unused")
+public class ScreenshotPopupPatch {
+
+ public static boolean disableScreenshotPopup() {
+ return Settings.DISABLE_SCREENSHOT_POPUP.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/ToolBarButtonPatch.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/ToolBarButtonPatch.java
new file mode 100644
index 0000000000..46a82cd0df
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/patches/ToolBarButtonPatch.java
@@ -0,0 +1,16 @@
+package app.revanced.extension.reddit.patches;
+
+import android.view.View;
+
+import app.revanced.extension.reddit.settings.Settings;
+
+@SuppressWarnings("unused")
+public class ToolBarButtonPatch {
+
+ public static void hideToolBarButton(View view) {
+ if (!Settings.HIDE_TOOLBAR_BUTTON.get())
+ return;
+
+ view.setVisibility(View.GONE);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/ActivityHook.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/ActivityHook.java
new file mode 100644
index 0000000000..ffe8bfd7d4
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/ActivityHook.java
@@ -0,0 +1,35 @@
+package app.revanced.extension.reddit.settings;
+
+import android.app.Activity;
+import android.view.View;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+
+import app.revanced.extension.reddit.settings.preference.ReVancedPreferenceFragment;
+
+/**
+ * @noinspection ALL
+ */
+public class ActivityHook {
+ public static void initialize(Activity activity) {
+ SettingsStatus.load();
+
+ final int fragmentId = View.generateViewId();
+ final FrameLayout fragment = new FrameLayout(activity);
+ fragment.setLayoutParams(new FrameLayout.LayoutParams(-1, -1));
+ fragment.setId(fragmentId);
+
+ final LinearLayout linearLayout = new LinearLayout(activity);
+ linearLayout.setLayoutParams(new LinearLayout.LayoutParams(-1, -1));
+ linearLayout.setOrientation(LinearLayout.VERTICAL);
+ linearLayout.setFitsSystemWindows(true);
+ linearLayout.setTransitionGroup(true);
+ linearLayout.addView(fragment);
+ activity.setContentView(linearLayout);
+
+ activity.getFragmentManager()
+ .beginTransaction()
+ .replace(fragmentId, new ReVancedPreferenceFragment())
+ .commit();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/Settings.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/Settings.java
new file mode 100644
index 0000000000..2efc2eb372
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/Settings.java
@@ -0,0 +1,30 @@
+package app.revanced.extension.reddit.settings;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.BooleanSetting;
+
+public class Settings extends BaseSettings {
+ // Ads
+ public static final BooleanSetting HIDE_COMMENT_ADS = new BooleanSetting("revanced_hide_comment_ads", TRUE, true);
+ public static final BooleanSetting HIDE_OLD_POST_ADS = new BooleanSetting("revanced_hide_old_post_ads", TRUE, true);
+ public static final BooleanSetting HIDE_NEW_POST_ADS = new BooleanSetting("revanced_hide_new_post_ads", TRUE, true);
+
+ // Layout
+ public static final BooleanSetting DISABLE_SCREENSHOT_POPUP = new BooleanSetting("revanced_disable_screenshot_popup", TRUE);
+ public static final BooleanSetting HIDE_CHAT_BUTTON = new BooleanSetting("revanced_hide_chat_button", FALSE, true);
+ public static final BooleanSetting HIDE_CREATE_BUTTON = new BooleanSetting("revanced_hide_create_button", FALSE, true);
+ public static final BooleanSetting HIDE_DISCOVER_BUTTON = new BooleanSetting("revanced_hide_discover_button", FALSE, true);
+ public static final BooleanSetting HIDE_RECENTLY_VISITED_SHELF = new BooleanSetting("revanced_hide_recently_visited_shelf", FALSE);
+ public static final BooleanSetting HIDE_RECOMMENDED_COMMUNITIES_SHELF = new BooleanSetting("revanced_hide_recommended_communities_shelf", FALSE, true);
+ public static final BooleanSetting HIDE_TOOLBAR_BUTTON = new BooleanSetting("revanced_hide_toolbar_button", FALSE, true);
+ public static final BooleanSetting REMOVE_NSFW_DIALOG = new BooleanSetting("revanced_remove_nsfw_dialog", FALSE, true);
+ public static final BooleanSetting REMOVE_NOTIFICATION_DIALOG = new BooleanSetting("revanced_remove_notification_dialog", FALSE, true);
+
+ // Miscellaneous
+ public static final BooleanSetting OPEN_LINKS_DIRECTLY = new BooleanSetting("revanced_open_links_directly", TRUE);
+ public static final BooleanSetting OPEN_LINKS_EXTERNALLY = new BooleanSetting("revanced_open_links_externally", TRUE);
+ public static final BooleanSetting SANITIZE_URL_QUERY = new BooleanSetting("revanced_sanitize_url_query", TRUE);
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/SettingsStatus.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/SettingsStatus.java
new file mode 100644
index 0000000000..a71521dabb
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/SettingsStatus.java
@@ -0,0 +1,78 @@
+package app.revanced.extension.reddit.settings;
+
+@SuppressWarnings("unused")
+public class SettingsStatus {
+ public static boolean generalAdsEnabled = false;
+ public static boolean navigationButtonsEnabled = false;
+ public static boolean openLinksDirectlyEnabled = false;
+ public static boolean openLinksExternallyEnabled = false;
+ public static boolean recentlyVisitedShelfEnabled = false;
+ public static boolean recommendedCommunitiesShelfEnabled = false;
+ public static boolean sanitizeUrlQueryEnabled = false;
+ public static boolean screenshotPopupEnabled = false;
+ public static boolean subRedditDialogEnabled = false;
+ public static boolean toolBarButtonEnabled = false;
+
+
+ public static void enableGeneralAds() {
+ generalAdsEnabled = true;
+ }
+
+ public static void enableNavigationButtons() {
+ navigationButtonsEnabled = true;
+ }
+
+ public static void enableOpenLinksDirectly() {
+ openLinksDirectlyEnabled = true;
+ }
+
+ public static void enableOpenLinksExternally() {
+ openLinksExternallyEnabled = true;
+ }
+
+ public static void enableRecentlyVisitedShelf() {
+ recentlyVisitedShelfEnabled = true;
+ }
+
+ public static void enableRecommendedCommunitiesShelf() {
+ recommendedCommunitiesShelfEnabled = true;
+ }
+
+ public static void enableSubRedditDialog() {
+ subRedditDialogEnabled = true;
+ }
+
+ public static void enableSanitizeUrlQuery() {
+ sanitizeUrlQueryEnabled = true;
+ }
+
+ public static void enableScreenshotPopup() {
+ screenshotPopupEnabled = true;
+ }
+
+ public static void enableToolBarButton() {
+ toolBarButtonEnabled = true;
+ }
+
+ public static boolean adsCategoryEnabled() {
+ return generalAdsEnabled;
+ }
+
+ public static boolean layoutCategoryEnabled() {
+ return navigationButtonsEnabled ||
+ recentlyVisitedShelfEnabled ||
+ screenshotPopupEnabled ||
+ subRedditDialogEnabled ||
+ toolBarButtonEnabled;
+ }
+
+ public static boolean miscellaneousCategoryEnabled() {
+ return openLinksDirectlyEnabled ||
+ openLinksExternallyEnabled ||
+ sanitizeUrlQueryEnabled;
+ }
+
+ public static void load() {
+
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/ReVancedPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/ReVancedPreferenceFragment.java
new file mode 100644
index 0000000000..8451a5819b
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/ReVancedPreferenceFragment.java
@@ -0,0 +1,46 @@
+package app.revanced.extension.reddit.settings.preference;
+
+import android.content.Context;
+import android.preference.Preference;
+import android.preference.PreferenceScreen;
+
+import androidx.annotation.NonNull;
+
+import org.jetbrains.annotations.NotNull;
+
+import app.revanced.extension.reddit.settings.preference.categories.AdsPreferenceCategory;
+import app.revanced.extension.reddit.settings.preference.categories.LayoutPreferenceCategory;
+import app.revanced.extension.reddit.settings.preference.categories.MiscellaneousPreferenceCategory;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment;
+
+/**
+ * Preference fragment for ReVanced settings
+ */
+@SuppressWarnings("deprecation")
+public class ReVancedPreferenceFragment extends AbstractPreferenceFragment {
+
+ @Override
+ protected void syncSettingWithPreference(@NonNull @NotNull Preference pref,
+ @NonNull @NotNull Setting> setting,
+ boolean applySettingToPreference) {
+ super.syncSettingWithPreference(pref, setting, applySettingToPreference);
+ }
+
+ @Override
+ protected void initialize() {
+ final Context context = getContext();
+
+ // Currently no resources can be compiled for Reddit (fails with aapt error).
+ // So all Reddit Strings are hard coded in integrations.
+ restartDialogMessage = "Refresh and restart";
+
+ PreferenceScreen preferenceScreen = getPreferenceManager().createPreferenceScreen(context);
+ setPreferenceScreen(preferenceScreen);
+
+ // Custom categories reference app specific Settings class.
+ new AdsPreferenceCategory(context, preferenceScreen);
+ new LayoutPreferenceCategory(context, preferenceScreen);
+ new MiscellaneousPreferenceCategory(context, preferenceScreen);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/TogglePreference.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/TogglePreference.java
new file mode 100644
index 0000000000..fed5a7c4b4
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/TogglePreference.java
@@ -0,0 +1,17 @@
+package app.revanced.extension.reddit.settings.preference;
+
+import android.content.Context;
+import android.preference.SwitchPreference;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+
+@SuppressWarnings("deprecation")
+public class TogglePreference extends SwitchPreference {
+ public TogglePreference(Context context, String title, String summary, BooleanSetting setting) {
+ super(context);
+ this.setTitle(title);
+ this.setSummary(summary);
+ this.setKey(setting.key);
+ this.setChecked(setting.get());
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/AdsPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/AdsPreferenceCategory.java
new file mode 100644
index 0000000000..a51fc397d4
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/AdsPreferenceCategory.java
@@ -0,0 +1,43 @@
+package app.revanced.extension.reddit.settings.preference.categories;
+
+import android.content.Context;
+import android.preference.PreferenceScreen;
+
+import app.revanced.extension.reddit.settings.Settings;
+import app.revanced.extension.reddit.settings.SettingsStatus;
+import app.revanced.extension.reddit.settings.preference.TogglePreference;
+
+@SuppressWarnings("deprecation")
+public class AdsPreferenceCategory extends ConditionalPreferenceCategory {
+ public AdsPreferenceCategory(Context context, PreferenceScreen screen) {
+ super(context, screen);
+ setTitle("Ads");
+ }
+
+ @Override
+ public boolean getSettingsStatus() {
+ return SettingsStatus.adsCategoryEnabled();
+ }
+
+ @Override
+ public void addPreferences(Context context) {
+ addPreference(new TogglePreference(
+ context,
+ "Hide comment ads",
+ "Hides ads in the comments section.",
+ Settings.HIDE_COMMENT_ADS
+ ));
+ addPreference(new TogglePreference(
+ context,
+ "Hide feed ads",
+ "Hides ads in the feed (old method).",
+ Settings.HIDE_OLD_POST_ADS
+ ));
+ addPreference(new TogglePreference(
+ context,
+ "Hide feed ads",
+ "Hides ads in the feed (new method).",
+ Settings.HIDE_NEW_POST_ADS
+ ));
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/ConditionalPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/ConditionalPreferenceCategory.java
new file mode 100644
index 0000000000..c82b7c129d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/ConditionalPreferenceCategory.java
@@ -0,0 +1,22 @@
+package app.revanced.extension.reddit.settings.preference.categories;
+
+import android.content.Context;
+import android.preference.PreferenceCategory;
+import android.preference.PreferenceScreen;
+
+@SuppressWarnings("deprecation")
+public abstract class ConditionalPreferenceCategory extends PreferenceCategory {
+ public ConditionalPreferenceCategory(Context context, PreferenceScreen screen) {
+ super(context);
+
+ if (getSettingsStatus()) {
+ screen.addPreference(this);
+ addPreferences(context);
+ }
+ }
+
+ public abstract boolean getSettingsStatus();
+
+ public abstract void addPreferences(Context context);
+}
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/LayoutPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/LayoutPreferenceCategory.java
new file mode 100644
index 0000000000..18dfd3349d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/LayoutPreferenceCategory.java
@@ -0,0 +1,91 @@
+package app.revanced.extension.reddit.settings.preference.categories;
+
+import android.content.Context;
+import android.preference.PreferenceScreen;
+
+import app.revanced.extension.reddit.settings.Settings;
+import app.revanced.extension.reddit.settings.SettingsStatus;
+import app.revanced.extension.reddit.settings.preference.TogglePreference;
+
+@SuppressWarnings("deprecation")
+public class LayoutPreferenceCategory extends ConditionalPreferenceCategory {
+ public LayoutPreferenceCategory(Context context, PreferenceScreen screen) {
+ super(context, screen);
+ setTitle("Layout");
+ }
+
+ @Override
+ public boolean getSettingsStatus() {
+ return SettingsStatus.layoutCategoryEnabled();
+ }
+
+ @Override
+ public void addPreferences(Context context) {
+ if (SettingsStatus.screenshotPopupEnabled) {
+ addPreference(new TogglePreference(
+ context,
+ "Disable screenshot popup",
+ "Disables the popup that appears when taking a screenshot.",
+ Settings.DISABLE_SCREENSHOT_POPUP
+ ));
+ }
+ if (SettingsStatus.navigationButtonsEnabled) {
+ addPreference(new TogglePreference(
+ context,
+ "Hide Chat button",
+ "Hides the Chat button in the navigation bar.",
+ Settings.HIDE_CHAT_BUTTON
+ ));
+ addPreference(new TogglePreference(
+ context,
+ "Hide Create button",
+ "Hides the Create button in the navigation bar.",
+ Settings.HIDE_CREATE_BUTTON
+ ));
+ addPreference(new TogglePreference(
+ context,
+ "Hide Discover or Communities button",
+ "Hides the Discover or Communities button in the navigation bar.",
+ Settings.HIDE_DISCOVER_BUTTON
+ ));
+ }
+ if (SettingsStatus.recentlyVisitedShelfEnabled) {
+ addPreference(new TogglePreference(
+ context,
+ "Hide Recently Visited shelf",
+ "Hides the Recently Visited shelf in the sidebar.",
+ Settings.HIDE_RECENTLY_VISITED_SHELF
+ ));
+ }
+ if (SettingsStatus.recommendedCommunitiesShelfEnabled) {
+ addPreference(new TogglePreference(
+ context,
+ "Hide recommended communities",
+ "Hides the recommended communities shelves in subreddits.",
+ Settings.HIDE_RECOMMENDED_COMMUNITIES_SHELF
+ ));
+ }
+ if (SettingsStatus.toolBarButtonEnabled) {
+ addPreference(new TogglePreference(
+ context,
+ "Hide toolbar button",
+ "Hide toolbar button",
+ Settings.HIDE_TOOLBAR_BUTTON
+ ));
+ }
+ if (SettingsStatus.subRedditDialogEnabled) {
+ addPreference(new TogglePreference(
+ context,
+ "Remove NSFW warning dialog",
+ "Removes the NSFW warning dialog that appears when visiting a subreddit by accepting it automatically.",
+ Settings.REMOVE_NSFW_DIALOG
+ ));
+ addPreference(new TogglePreference(
+ context,
+ "Remove notification suggestion dialog",
+ "Removes the notifications suggestion dialog that appears when visiting a subreddit by dismissing it automatically.",
+ Settings.REMOVE_NOTIFICATION_DIALOG
+ ));
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/MiscellaneousPreferenceCategory.java b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/MiscellaneousPreferenceCategory.java
new file mode 100644
index 0000000000..5e16cf5b82
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/reddit/settings/preference/categories/MiscellaneousPreferenceCategory.java
@@ -0,0 +1,49 @@
+package app.revanced.extension.reddit.settings.preference.categories;
+
+import android.content.Context;
+import android.preference.PreferenceScreen;
+
+import app.revanced.extension.reddit.settings.Settings;
+import app.revanced.extension.reddit.settings.SettingsStatus;
+import app.revanced.extension.reddit.settings.preference.TogglePreference;
+
+@SuppressWarnings("deprecation")
+public class MiscellaneousPreferenceCategory extends ConditionalPreferenceCategory {
+ public MiscellaneousPreferenceCategory(Context context, PreferenceScreen screen) {
+ super(context, screen);
+ setTitle("Miscellaneous");
+ }
+
+ @Override
+ public boolean getSettingsStatus() {
+ return SettingsStatus.miscellaneousCategoryEnabled();
+ }
+
+ @Override
+ public void addPreferences(Context context) {
+ if (SettingsStatus.openLinksDirectlyEnabled) {
+ addPreference(new TogglePreference(
+ context,
+ "Open links directly",
+ "Skips over redirection URLs in external links.",
+ Settings.OPEN_LINKS_DIRECTLY
+ ));
+ }
+ if (SettingsStatus.openLinksExternallyEnabled) {
+ addPreference(new TogglePreference(
+ context,
+ "Open links externally",
+ "Opens links in your browser instead of in the in-app-browser.",
+ Settings.OPEN_LINKS_EXTERNALLY
+ ));
+ }
+ if (SettingsStatus.sanitizeUrlQueryEnabled) {
+ addPreference(new TogglePreference(
+ context,
+ "Sanitize sharing links",
+ "Removes tracking query parameters from URLs when sharing links.",
+ Settings.SANITIZE_URL_QUERY
+ ));
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/AutoCaptionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/AutoCaptionsPatch.java
new file mode 100644
index 0000000000..2a9752df81
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/AutoCaptionsPatch.java
@@ -0,0 +1,18 @@
+package app.revanced.extension.shared.patches;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+
+@SuppressWarnings("unused")
+public final class AutoCaptionsPatch {
+
+ private static boolean captionsButtonStatus;
+
+ public static boolean disableAutoCaptions() {
+ return BaseSettings.DISABLE_AUTO_CAPTIONS.get() &&
+ !captionsButtonStatus;
+ }
+
+ public static void setCaptionsButtonStatus(boolean status) {
+ captionsButtonStatus = status;
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/BaseSettingsMenuPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/BaseSettingsMenuPatch.java
new file mode 100644
index 0000000000..4ce7e63a8e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/BaseSettingsMenuPatch.java
@@ -0,0 +1,16 @@
+package app.revanced.extension.shared.patches;
+
+import android.util.Log;
+
+import androidx.preference.PreferenceScreen;
+
+@SuppressWarnings("unused")
+public class BaseSettingsMenuPatch {
+
+ /**
+ * Rest of the implementation added by patch.
+ */
+ public static void removePreference(PreferenceScreen mPreferenceScreen, String key) {
+ Log.d("Extended: SettingsMenuPatch", "key: " + key);
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/BypassImageRegionRestrictionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/BypassImageRegionRestrictionsPatch.java
new file mode 100644
index 0000000000..a43849f404
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/BypassImageRegionRestrictionsPatch.java
@@ -0,0 +1,34 @@
+package app.revanced.extension.shared.patches;
+
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.utils.Logger;
+
+@SuppressWarnings("unused")
+public final class BypassImageRegionRestrictionsPatch {
+
+ private static final boolean BYPASS_IMAGE_REGION_RESTRICTIONS_ENABLED = BaseSettings.BYPASS_IMAGE_REGION_RESTRICTIONS.get();
+ private static final String REPLACEMENT_IMAGE_DOMAIN = BaseSettings.BYPASS_IMAGE_REGION_RESTRICTIONS_DOMAIN.get();
+
+ /**
+ * YouTube static images domain. Includes user and channel avatar images and community post images.
+ */
+ private static final Pattern YOUTUBE_STATIC_IMAGE_DOMAIN_PATTERN = Pattern.compile("(ap[1-2]|gm[1-4]|gz0|(cp|ci|gp|lh)[3-6]|sp[1-3]|yt[3-4]|(play|ccp)-lh)\\.(ggpht|googleusercontent)\\.com");
+
+ public static String overrideImageURL(String originalUrl) {
+ try {
+ if (BYPASS_IMAGE_REGION_RESTRICTIONS_ENABLED) {
+ final String replacement = YOUTUBE_STATIC_IMAGE_DOMAIN_PATTERN
+ .matcher(originalUrl).replaceFirst(REPLACEMENT_IMAGE_DOMAIN);
+ if (!replacement.equals(originalUrl)) {
+ Logger.printDebug(() -> "Replaced: '" + originalUrl + "' with: '" + replacement + "'");
+ }
+ return replacement;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "overrideImageURL failure", ex);
+ }
+ return originalUrl;
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/FullscreenAdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/FullscreenAdsPatch.java
new file mode 100644
index 0000000000..341f8748e4
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/FullscreenAdsPatch.java
@@ -0,0 +1,74 @@
+package app.revanced.extension.shared.patches;
+
+import static app.revanced.extension.shared.utils.Utils.hideViewBy0dpUnderCondition;
+
+import android.view.View;
+
+import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.utils.Logger;
+
+@SuppressWarnings("unused")
+public class FullscreenAdsPatch {
+ private static final boolean hideFullscreenAdsEnabled = BaseSettings.HIDE_FULLSCREEN_ADS.get();
+ private static final ByteArrayFilterGroup exception =
+ new ByteArrayFilterGroup(
+ null,
+ "post_image_lightbox.eml" // Community post image in fullscreen
+ );
+
+ public static boolean disableFullscreenAds(final byte[] bytes, int type) {
+ if (!hideFullscreenAdsEnabled) {
+ return false;
+ }
+
+ final DialogType dialogType = DialogType.getDialogType(type);
+ final String dialogName = dialogType.name();
+
+ // The dialog type of a fullscreen dialog is always {@code DialogType.FULLSCREEN}
+ if (dialogType != DialogType.FULLSCREEN) {
+ Logger.printDebug(() -> "Ignoring dialogType " + dialogName);
+ return false;
+ }
+
+ // Image in community post in fullscreen is not filtered
+ final boolean isException = bytes != null &&
+ exception.check(bytes).isFiltered();
+
+ if (isException) {
+ Logger.printDebug(() -> "Ignoring exception");
+ } else {
+ Logger.printDebug(() -> "Blocked fullscreen ads");
+ }
+
+ return !isException;
+ }
+
+ public static void hideFullscreenAds(View view) {
+ hideViewBy0dpUnderCondition(
+ hideFullscreenAdsEnabled,
+ view
+ );
+ }
+
+ private enum DialogType {
+ NULL(0),
+ ALERT(1),
+ FULLSCREEN(2),
+ LAYOUT_FULLSCREEN(3);
+
+ private final int type;
+
+ DialogType(int type) {
+ this.type = type;
+ }
+
+ private static DialogType getDialogType(int type) {
+ for (DialogType val : values())
+ if (type == val.type) return val;
+
+ return DialogType.NULL;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/GmsCoreSupport.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/GmsCoreSupport.java
new file mode 100644
index 0000000000..96a049b877
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/GmsCoreSupport.java
@@ -0,0 +1,233 @@
+package app.revanced.extension.shared.patches;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.shared.utils.Utils.isSDKAbove;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.SearchManager;
+import android.content.ContentProviderClient;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.PowerManager;
+import android.provider.Settings;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+@SuppressWarnings("unused")
+public class GmsCoreSupport {
+ private static final String PACKAGE_NAME_YOUTUBE = "com.google.android.youtube";
+ private static final String PACKAGE_NAME_YOUTUBE_MUSIC = "com.google.android.apps.youtube.music";
+
+ private static final String GMS_CORE_PACKAGE_NAME
+ = getGmsCoreVendorGroupId() + ".android.gms";
+ private static final Uri GMS_CORE_PROVIDER
+ = Uri.parse("content://" + getGmsCoreVendorGroupId() + ".android.gsf.gservices/prefix");
+ private static final String DONT_KILL_MY_APP_LINK
+ = "https://dontkillmyapp.com";
+
+ private static final String META_SPOOF_PACKAGE_NAME =
+ GMS_CORE_PACKAGE_NAME + ".SPOOFED_PACKAGE_NAME";
+
+ private static void open(Activity mActivity, String queryOrLink) {
+ Intent intent;
+ try {
+ // Check if queryOrLink is a valid URL.
+ new URL(queryOrLink);
+
+ intent = new Intent(Intent.ACTION_VIEW, Uri.parse(queryOrLink));
+ } catch (MalformedURLException e) {
+ intent = new Intent(Intent.ACTION_WEB_SEARCH);
+ intent.putExtra(SearchManager.QUERY, queryOrLink);
+ }
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ mActivity.startActivity(intent);
+
+ // Gracefully exit, otherwise the broken app will continue to run.
+ System.exit(0);
+ }
+
+ private static void showBatteryOptimizationDialog(Activity context,
+ String dialogMessageRef,
+ String positiveButtonStringRef,
+ DialogInterface.OnClickListener onPositiveClickListener) {
+ // Use a delay to allow the activity to finish initializing.
+ // Otherwise, if device is in dark mode the dialog is shown with wrong color scheme.
+ Utils.runOnMainThreadDelayed(() -> new AlertDialog.Builder(context)
+ .setIconAttribute(android.R.attr.alertDialogIcon)
+ .setTitle(str("gms_core_dialog_title"))
+ .setMessage(str(dialogMessageRef))
+ .setPositiveButton(str(positiveButtonStringRef), onPositiveClickListener)
+ // Allow using back button to skip the action, just in case the check can never be satisfied.
+ .setCancelable(true)
+ .show(), 100);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void checkGmsCore(Activity mActivity) {
+ try {
+ // Verify the user has not included GmsCore for a root installation.
+ // GmsCore Support changes the package name, but with a mounted installation
+ // all manifest changes are ignored and the original package name is used.
+ if (StringUtils.equalsAny(mActivity.getPackageName(), PACKAGE_NAME_YOUTUBE, PACKAGE_NAME_YOUTUBE_MUSIC)) {
+ Logger.printInfo(() -> "App is mounted with root, but GmsCore patch was included");
+ // Cannot use localize text here, since the app will load
+ // resources from the unpatched app and all patch strings are missing.
+ Utils.showToastLong("The 'GmsCore support' patch breaks mount installations");
+
+ // Do not exit. If the app exits before launch completes (and without
+ // opening another activity), then on some devices such as Pixel phone Android 10
+ // no toast will be shown and the app will continually be relaunched
+ // with the appearance of a hung app.
+ }
+
+ // Verify GmsCore is installed.
+ try {
+ PackageManager manager = mActivity.getPackageManager();
+ manager.getPackageInfo(GMS_CORE_PACKAGE_NAME, PackageManager.GET_ACTIVITIES);
+ } catch (PackageManager.NameNotFoundException exception) {
+ Logger.printInfo(() -> "GmsCore was not found");
+ // Cannot show a dialog and must show a toast,
+ // because on some installations the app crashes before a dialog can be displayed.
+ Utils.showToastLong(str("gms_core_toast_not_installed_message"));
+ open(mActivity, getGmsCoreDownload());
+ return;
+ }
+
+ if (contentProviderClientUnAvailable(mActivity)) {
+ Logger.printInfo(() -> "GmsCore is not running in the background");
+
+ showBatteryOptimizationDialog(mActivity,
+ "gms_core_dialog_not_whitelisted_not_allowed_in_background_message",
+ "gms_core_dialog_open_website_text",
+ (dialog, id) -> open(mActivity, DONT_KILL_MY_APP_LINK));
+ return;
+ }
+
+ // Check if GmsCore is whitelisted from battery optimizations.
+ if (batteryOptimizationsEnabled(mActivity)) {
+ Logger.printInfo(() -> "GmsCore is not whitelisted from battery optimizations");
+ showBatteryOptimizationDialog(mActivity,
+ "gms_core_dialog_not_whitelisted_using_battery_optimizations_message",
+ "gms_core_dialog_continue_text",
+ (dialog, id) -> openGmsCoreDisableBatteryOptimizationsIntent(mActivity));
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "checkGmsCore failure", ex);
+ }
+ }
+
+ /**
+ * @return If GmsCore is not running in the background.
+ */
+ @SuppressWarnings("deprecation")
+ private static boolean contentProviderClientUnAvailable(Context context) {
+ // Check if GmsCore is running in the background.
+ // Do this check before the battery optimization check.
+ if (isSDKAbove(24)) {
+ try (ContentProviderClient client = context.getContentResolver().acquireContentProviderClient(GMS_CORE_PROVIDER)) {
+ return client == null;
+ }
+ } else {
+ ContentProviderClient client = null;
+ try {
+ //noinspection resource
+ client = context.getContentResolver()
+ .acquireContentProviderClient(GMS_CORE_PROVIDER);
+ return client == null;
+ } finally {
+ if (client != null) client.release();
+ }
+ }
+ }
+
+ @SuppressLint("BatteryLife") // Permission is part of GmsCore
+ private static void openGmsCoreDisableBatteryOptimizationsIntent(Activity mActivity) {
+ if (!isSDKAbove(23)) return;
+ Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
+ intent.setData(Uri.fromParts("package", GMS_CORE_PACKAGE_NAME, null));
+ mActivity.startActivityForResult(intent, 0);
+ }
+
+ /**
+ * @return If GmsCore is not whitelisted from battery optimizations.
+ */
+ private static boolean batteryOptimizationsEnabled(Context context) {
+ if (isSDKAbove(23) && context.getSystemService(Context.POWER_SERVICE) instanceof PowerManager powerManager) {
+ return !powerManager.isIgnoringBatteryOptimizations(GMS_CORE_PACKAGE_NAME);
+ }
+ return false;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static String spoofPackageName(Context context) {
+ // Package name of ReVanced.
+ final String packageName = context.getPackageName();
+
+ try {
+ final PackageManager packageManager = context.getPackageManager();
+
+ // Package name of YouTube or YouTube Music.
+ String originalPackageName;
+
+ try {
+ originalPackageName = packageManager
+ .getPackageInfo(packageName, PackageManager.GET_META_DATA)
+ .applicationInfo
+ .metaData
+ .getString(META_SPOOF_PACKAGE_NAME);
+ } catch (PackageManager.NameNotFoundException exception) {
+ Logger.printDebug(() -> "Failed to parsing metadata");
+ return packageName;
+ }
+
+ if (StringUtils.isBlank(originalPackageName)) {
+ Logger.printDebug(() -> "Failed to parsing spoofed package name");
+ return packageName;
+ }
+
+ try {
+ packageManager.getPackageInfo(originalPackageName, PackageManager.GET_ACTIVITIES);
+ } catch (PackageManager.NameNotFoundException exception) {
+ Logger.printDebug(() -> "Original app '" + originalPackageName + "' was not found");
+ return packageName;
+ }
+
+ Logger.printDebug(() -> "Package name of '" + packageName + "' spoofed to '" + originalPackageName + "'");
+
+ return originalPackageName;
+ } catch (Exception ex) {
+ Logger.printException(() -> "spoofPackageName failure", ex);
+ }
+
+ return packageName;
+ }
+
+ private static String getGmsCoreDownload() {
+ final String vendorGroupId = getGmsCoreVendorGroupId();
+ return switch (vendorGroupId) {
+ case "app.revanced" -> "https://github.com/revanced/gmscore/releases/latest";
+ case "com.mgoogle" -> "https://github.com/inotia00/VancedMicroG/releases/latest";
+ default -> vendorGroupId + ".android.gms";
+ };
+ }
+
+ // Modified by a patch. Do not touch.
+ private static String getGmsCoreVendorGroupId() {
+ return "app.revanced";
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/ReturnYouTubeUsernamePatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/ReturnYouTubeUsernamePatch.java
new file mode 100644
index 0000000000..32e177c17d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/ReturnYouTubeUsernamePatch.java
@@ -0,0 +1,113 @@
+package app.revanced.extension.shared.patches;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+
+import android.text.SpannableString;
+import android.text.Spanned;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.shared.returnyoutubeusername.requests.ChannelRequest;
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.utils.Logger;
+
+@SuppressWarnings("unused")
+public class ReturnYouTubeUsernamePatch {
+ private static final boolean RETURN_YOUTUBE_USERNAME_ENABLED = BaseSettings.RETURN_YOUTUBE_USERNAME_ENABLED.get();
+ private static final Boolean RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT = BaseSettings.RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT.get().userNameFirst;
+ private static final String YOUTUBE_API_KEY = BaseSettings.RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY.get();
+
+ private static final String AUTHOR_BADGE_PATH = "|author_badge.eml|";
+ private static volatile String lastFetchedHandle = "";
+
+ /**
+ * Injection point.
+ *
+ * @param original The original string before the SpannableString is built.
+ */
+ public static CharSequence preFetchLithoText(@NonNull Object conversionContext,
+ @NonNull CharSequence original) {
+ onLithoTextLoaded(conversionContext, original, true);
+ return original;
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param original The original string after the SpannableString is built.
+ */
+ @NonNull
+ public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext,
+ @NonNull CharSequence original) {
+ return onLithoTextLoaded(conversionContext, original, false);
+ }
+
+ @NonNull
+ private static CharSequence onLithoTextLoaded(@NonNull Object conversionContext,
+ @NonNull CharSequence original,
+ boolean fetchNeeded) {
+ try {
+ if (!RETURN_YOUTUBE_USERNAME_ENABLED) {
+ return original;
+ }
+ if (YOUTUBE_API_KEY.isEmpty()) {
+ Logger.printDebug(() -> "API key is empty");
+ return original;
+ }
+ // In comments, the path to YouTube Handle(@youtube) always includes [AUTHOR_BADGE_PATH].
+ if (!conversionContext.toString().contains(AUTHOR_BADGE_PATH)) {
+ return original;
+ }
+ String handle = original.toString();
+ if (fetchNeeded && !handle.equals(lastFetchedHandle)) {
+ lastFetchedHandle = handle;
+ // Get the original username using YouTube Data API v3.
+ ChannelRequest.fetchRequestIfNeeded(handle, YOUTUBE_API_KEY, RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT);
+ return original;
+ }
+ // If the username is not in the cache, put it in the cache.
+ ChannelRequest channelRequest = ChannelRequest.getRequestForHandle(handle);
+ if (channelRequest == null) {
+ Logger.printDebug(() -> "ChannelRequest is null, handle:" + handle);
+ return original;
+ }
+ final String userName = channelRequest.getStream();
+ if (userName == null) {
+ Logger.printDebug(() -> "ChannelRequest Stream is null, handle:" + handle);
+ return original;
+ }
+ final CharSequence copiedSpannableString = copySpannableString(original, userName);
+ Logger.printDebug(() -> "Replaced: '" + original + "' with: '" + copiedSpannableString + "'");
+ return copiedSpannableString;
+ } catch (Exception ex) {
+ Logger.printException(() -> "onLithoTextLoaded failure", ex);
+ }
+ return original;
+ }
+
+ private static CharSequence copySpannableString(CharSequence original, String userName) {
+ if (original instanceof Spanned spanned) {
+ SpannableString newString = new SpannableString(userName);
+ Object[] spans = spanned.getSpans(0, spanned.length(), Object.class);
+ for (Object span : spans) {
+ int flags = spanned.getSpanFlags(span);
+ newString.setSpan(span, 0, newString.length(), flags);
+ }
+ return newString;
+ }
+ return original;
+ }
+
+ public enum DisplayFormat {
+ USERNAME_ONLY(null),
+ USERNAME_HANDLE(TRUE),
+ HANDLE_USERNAME(FALSE);
+
+ final Boolean userNameFirst;
+
+ DisplayFormat(Boolean userNameFirst) {
+ this.userNameFirst = userNameFirst;
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/SanitizeUrlQueryPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/SanitizeUrlQueryPatch.java
new file mode 100644
index 0000000000..c9e6c5d403
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/SanitizeUrlQueryPatch.java
@@ -0,0 +1,50 @@
+package app.revanced.extension.shared.patches;
+
+import android.content.Intent;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+
+@SuppressWarnings("all")
+public final class SanitizeUrlQueryPatch {
+ /**
+ * This tracking parameter is mainly used.
+ */
+ private static final String NEW_TRACKING_REGEX = ".si=.+";
+ /**
+ * This tracking parameter is outdated.
+ * Used when patching old versions or enabling spoof app version.
+ */
+ private static final String OLD_TRACKING_REGEX = ".feature=.+";
+ private static final String URL_PROTOCOL = "http";
+
+ /**
+ * Strip query parameters from a given URL string.
+ *
+ * URL example containing tracking parameter:
+ * https://youtu.be/ZWgr7qP6yhY?si=kKA_-9cygieuFY7R
+ * https://youtu.be/ZWgr7qP6yhY?feature=shared
+ * https://youtube.com/watch?v=ZWgr7qP6yhY&si=s_PZAxnJHKX1Mc8C
+ * https://youtube.com/watch?v=ZWgr7qP6yhY&feature=shared
+ * https://youtube.com/playlist?list=PLBsP89CPrMeO7uztAu6YxSB10cRMpjgiY&si=N0U8xncY2ZmQoSMp
+ * https://youtube.com/playlist?list=PLBsP89CPrMeO7uztAu6YxSB10cRMpjgiY&feature=shared
+ *
+ * Since we need to support support all these examples,
+ * We cannot use [URL.getpath()] or [Uri.getQueryParameter()].
+ *
+ * @param urlString URL string to strip query parameters from.
+ * @return URL string without query parameters if possible, otherwise the original string.
+ */
+ public static String stripQueryParameters(final String urlString) {
+ if (!BaseSettings.SANITIZE_SHARING_LINKS.get())
+ return urlString;
+
+ return urlString.replaceAll(NEW_TRACKING_REGEX, "").replaceAll(OLD_TRACKING_REGEX, "");
+ }
+
+ public static void stripQueryParameters(final Intent intent, final String extraName, final String extraValue) {
+ intent.putExtra(extraName, extraValue.startsWith(URL_PROTOCOL)
+ ? stripQueryParameters(extraValue)
+ : extraValue
+ );
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/ByteArrayFilterGroup.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/ByteArrayFilterGroup.java
new file mode 100644
index 0000000000..18a94365cf
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/ByteArrayFilterGroup.java
@@ -0,0 +1,98 @@
+package app.revanced.extension.shared.patches.components;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.utils.ByteTrieSearch;
+import app.revanced.extension.shared.utils.Logger;
+
+/**
+ * If you have more than 1 filter patterns, then all instances of
+ * this class should filtered using {@link ByteArrayFilterGroupList#check(byte[])},
+ * which uses a prefix tree to give better performance.
+ */
+@SuppressWarnings("unused")
+public class ByteArrayFilterGroup extends FilterGroup {
+
+ private volatile int[][] failurePatterns;
+
+ // Modified implementation from https://stackoverflow.com/a/1507813
+ private static int indexOf(final byte[] data, final byte[] pattern, final int[] failure) {
+ // Finds the first occurrence of the pattern in the byte array using
+ // KMP matching algorithm.
+ int patternLength = pattern.length;
+ for (int i = 0, j = 0, dataLength = data.length; i < dataLength; i++) {
+ while (j > 0 && pattern[j] != data[i]) {
+ j = failure[j - 1];
+ }
+ if (pattern[j] == data[i]) {
+ j++;
+ }
+ if (j == patternLength) {
+ return i - patternLength + 1;
+ }
+ }
+ return -1;
+ }
+
+ private static int[] createFailurePattern(byte[] pattern) {
+ // Computes the failure function using a boot-strapping process,
+ // where the pattern is matched against itself.
+ final int patternLength = pattern.length;
+ final int[] failure = new int[patternLength];
+
+ for (int i = 1, j = 0; i < patternLength; i++) {
+ while (j > 0 && pattern[j] != pattern[i]) {
+ j = failure[j - 1];
+ }
+ if (pattern[j] == pattern[i]) {
+ j++;
+ }
+ failure[i] = j;
+ }
+ return failure;
+ }
+
+ public ByteArrayFilterGroup(BooleanSetting setting, byte[]... filters) {
+ super(setting, filters);
+ }
+
+ /**
+ * Converts the Strings into byte arrays. Used to search for text in binary data.
+ */
+ public ByteArrayFilterGroup(BooleanSetting setting, String... filters) {
+ super(setting, ByteTrieSearch.convertStringsToBytes(filters));
+ }
+
+ private synchronized void buildFailurePatterns() {
+ if (failurePatterns != null)
+ return; // Thread race and another thread already initialized the search.
+ Logger.printDebug(() -> "Building failure array for: " + this);
+ int[][] failurePatterns = new int[filters.length][];
+ int i = 0;
+ for (byte[] pattern : filters) {
+ failurePatterns[i++] = createFailurePattern(pattern);
+ }
+ this.failurePatterns = failurePatterns; // Must set after initialization finishes.
+ }
+
+ @Override
+ public FilterGroupResult check(final byte[] bytes) {
+ int matchedLength = 0;
+ int matchedIndex = -1;
+ if (isEnabled()) {
+ int[][] failures = failurePatterns;
+ if (failures == null) {
+ buildFailurePatterns(); // Lazy load.
+ failures = failurePatterns;
+ }
+ for (int i = 0, length = filters.length; i < length; i++) {
+ byte[] filter = filters[i];
+ matchedIndex = indexOf(bytes, filter, failures[i]);
+ if (matchedIndex >= 0) {
+ matchedLength = filter.length;
+ break;
+ }
+ }
+ }
+ return new FilterGroupResult(setting, matchedIndex, matchedLength);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/ByteArrayFilterGroupList.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/ByteArrayFilterGroupList.java
new file mode 100644
index 0000000000..52bbbbab0a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/ByteArrayFilterGroupList.java
@@ -0,0 +1,14 @@
+package app.revanced.extension.shared.patches.components;
+
+import app.revanced.extension.shared.utils.ByteTrieSearch;
+
+/**
+ * If searching for a single byte pattern, then it is slightly better to use
+ * {@link ByteArrayFilterGroup#check(byte[])} as it uses KMP which is faster
+ * than a prefix tree to search for only 1 pattern.
+ */
+public final class ByteArrayFilterGroupList extends FilterGroupList {
+ protected ByteTrieSearch createSearchGraph() {
+ return new ByteTrieSearch();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/Filter.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/Filter.java
new file mode 100644
index 0000000000..77123be16d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/Filter.java
@@ -0,0 +1,106 @@
+package app.revanced.extension.shared.patches.components;
+
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.utils.Logger;
+
+/**
+ * Filters litho based components.
+ *
+ * Callbacks to filter content are added using {@link #addIdentifierCallbacks(StringFilterGroup...)}
+ * and {@link #addPathCallbacks(StringFilterGroup...)}.
+ *
+ * To filter {@link FilterContentType#PROTOBUFFER}, first add a callback to
+ * either an identifier or a path.
+ * Then inside {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)}
+ * search for the buffer content using either a {@link ByteArrayFilterGroup} (if searching for 1 pattern)
+ * or a {@link ByteArrayFilterGroupList} (if searching for more than 1 pattern).
+ *
+ * All callbacks must be registered before the constructor completes.
+ */
+@SuppressWarnings("unused")
+public abstract class Filter {
+
+ public enum FilterContentType {
+ IDENTIFIER,
+ PATH,
+ ALLVALUE,
+ PROTOBUFFER
+ }
+
+ /**
+ * Identifier callbacks. Do not add to this instance,
+ * and instead use {@link #addIdentifierCallbacks(StringFilterGroup...)}.
+ */
+ protected final List identifierCallbacks = new ArrayList<>();
+ /**
+ * Path callbacks. Do not add to this instance,
+ * and instead use {@link #addPathCallbacks(StringFilterGroup...)}.
+ */
+ protected final List pathCallbacks = new ArrayList<>();
+ /**
+ * Path callbacks. Do not add to this instance,
+ * and instead use {@link #addAllValueCallbacks(StringFilterGroup...)}.
+ */
+ protected final List allValueCallbacks = new ArrayList<>();
+
+ /**
+ * Adds callbacks to {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)}
+ * if any of the groups are found.
+ */
+ protected final void addIdentifierCallbacks(StringFilterGroup... groups) {
+ identifierCallbacks.addAll(Arrays.asList(groups));
+ }
+
+ /**
+ * Adds callbacks to {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)}
+ * if any of the groups are found.
+ */
+ protected final void addPathCallbacks(StringFilterGroup... groups) {
+ pathCallbacks.addAll(Arrays.asList(groups));
+ }
+
+ /**
+ * Adds callbacks to {@link #isFiltered(String, String, String, byte[], StringFilterGroup, FilterContentType, int)}
+ * if any of the groups are found.
+ */
+ protected final void addAllValueCallbacks(StringFilterGroup... groups) {
+ allValueCallbacks.addAll(Arrays.asList(groups));
+ }
+
+ /**
+ * Called after an enabled filter has been matched.
+ * Default implementation is to always filter the matched component and log the action.
+ * Subclasses can perform additional or different checks if needed.
+ *
+ * If the content is to be filtered, subclasses should always
+ * call this method (and never return a plain 'true').
+ * That way the logs will always show when a component was filtered and which filter hide it.
+ *
+ * Method is called off the main thread.
+ *
+ * @param matchedGroup The actual filter that matched.
+ * @param contentType The type of content matched.
+ * @param contentIndex Matched index of the identifier or path.
+ * @return True if the litho component should be filtered out.
+ */
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (BaseSettings.ENABLE_DEBUG_LOGGING.get()) {
+ String filterSimpleName = getClass().getSimpleName();
+ if (contentType == FilterContentType.IDENTIFIER) {
+ Logger.printDebug(() -> filterSimpleName + " Filtered identifier: " + identifier);
+ } else if (contentType == FilterContentType.PATH) {
+ Logger.printDebug(() -> filterSimpleName + " Filtered path: " + path);
+ } else if (contentType == FilterContentType.ALLVALUE) {
+ Logger.printDebug(() -> filterSimpleName + " Filtered object: " + allValue);
+ }
+ }
+ return true;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/FilterGroup.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/FilterGroup.java
new file mode 100644
index 0000000000..e580ea5ceb
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/FilterGroup.java
@@ -0,0 +1,95 @@
+package app.revanced.extension.shared.patches.components;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+
+@SuppressWarnings("unused")
+public abstract class FilterGroup {
+ public final static class FilterGroupResult {
+ private BooleanSetting setting;
+ private int matchedIndex;
+ private int matchedLength;
+ // In the future it might be useful to include which pattern matched,
+ // but for now that is not needed.
+
+ FilterGroupResult() {
+ this(null, -1, 0);
+ }
+
+ FilterGroupResult(BooleanSetting setting, int matchedIndex, int matchedLength) {
+ setValues(setting, matchedIndex, matchedLength);
+ }
+
+ public void setValues(BooleanSetting setting, int matchedIndex, int matchedLength) {
+ this.setting = setting;
+ this.matchedIndex = matchedIndex;
+ this.matchedLength = matchedLength;
+ }
+
+ /**
+ * A null value if the group has no setting,
+ * or if no match is returned from {@link FilterGroupList#check(Object)}.
+ */
+ public BooleanSetting getSetting() {
+ return setting;
+ }
+
+ public boolean isFiltered() {
+ return matchedIndex >= 0;
+ }
+
+ /**
+ * Matched index of first pattern that matched, or -1 if nothing matched.
+ */
+ public int getMatchedIndex() {
+ return matchedIndex;
+ }
+
+ /**
+ * Length of the matched filter pattern.
+ */
+ public int getMatchedLength() {
+ return matchedLength;
+ }
+ }
+
+ protected final BooleanSetting setting;
+ protected final T[] filters;
+
+ /**
+ * Initialize a new filter group.
+ *
+ * @param setting The associated setting.
+ * @param filters The filters.
+ */
+ @SafeVarargs
+ public FilterGroup(final BooleanSetting setting, final T... filters) {
+ this.setting = setting;
+ this.filters = filters;
+ if (filters.length == 0) {
+ throw new IllegalArgumentException("Must use one or more filter patterns (zero specified)");
+ }
+ }
+
+ public boolean isEnabled() {
+ return setting == null || setting.get();
+ }
+
+ /**
+ * @return If {@link FilterGroupList} should include this group when searching.
+ * By default, all filters are included except non enabled settings that require reboot.
+ */
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ public boolean includeInSearch() {
+ return isEnabled() || !setting.rebootApp;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + ": " + (setting == null ? "(null setting)" : setting);
+ }
+
+ public abstract FilterGroupResult check(final T stack);
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/FilterGroupList.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/FilterGroupList.java
new file mode 100644
index 0000000000..62e08a7e24
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/FilterGroupList.java
@@ -0,0 +1,69 @@
+package app.revanced.extension.shared.patches.components;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Spliterator;
+import java.util.function.Consumer;
+
+import app.revanced.extension.shared.utils.TrieSearch;
+
+@SuppressWarnings("unused")
+public abstract class FilterGroupList> implements Iterable {
+
+ private final List filterGroups = new ArrayList<>();
+ private final TrieSearch search = createSearchGraph();
+
+ @SafeVarargs
+ public final void addAll(final T... groups) {
+ filterGroups.addAll(Arrays.asList(groups));
+
+ for (T group : groups) {
+ if (!group.includeInSearch()) {
+ continue;
+ }
+ for (V pattern : group.filters) {
+ search.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
+ if (group.isEnabled()) {
+ FilterGroup.FilterGroupResult result = (FilterGroup.FilterGroupResult) callbackParameter;
+ result.setValues(group.setting, matchedStartIndex, matchedLength);
+ return true;
+ }
+ return false;
+ });
+ }
+ }
+ }
+
+ @NonNull
+ @Override
+ public Iterator iterator() {
+ return filterGroups.iterator();
+ }
+
+ @RequiresApi(24)
+ @Override
+ public void forEach(@NonNull Consumer super T> action) {
+ filterGroups.forEach(action);
+ }
+
+ @RequiresApi(24)
+ @NonNull
+ @Override
+ public Spliterator spliterator() {
+ return filterGroups.spliterator();
+ }
+
+ public FilterGroup.FilterGroupResult check(V stack) {
+ FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult();
+ search.matches(stack, result);
+ return result;
+
+ }
+
+ protected abstract TrieSearch createSearchGraph();
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/LithoFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/LithoFilterPatch.java
new file mode 100644
index 0000000000..6e59379af9
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/LithoFilterPatch.java
@@ -0,0 +1,191 @@
+package app.revanced.extension.shared.patches.components;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.nio.ByteBuffer;
+import java.util.List;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.StringTrieSearch;
+
+@SuppressWarnings("unused")
+public final class LithoFilterPatch {
+ /**
+ * Simple wrapper to pass the litho parameters through the prefix search.
+ */
+ private static final class LithoFilterParameters {
+ @Nullable
+ final String identifier;
+ final String path;
+ final String allValue;
+ final byte[] protoBuffer;
+
+ LithoFilterParameters(String lithoPath, @Nullable String lithoIdentifier, String allValues, byte[] bufferArray) {
+ this.path = lithoPath;
+ this.identifier = lithoIdentifier;
+ this.allValue = allValues;
+ this.protoBuffer = bufferArray;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ // Estimate the percentage of the buffer that are Strings.
+ StringBuilder builder = new StringBuilder(Math.max(100, protoBuffer.length / 2));
+ builder.append("\nID: ");
+ builder.append(identifier);
+ builder.append("\nPath: ");
+ builder.append(path);
+ if (BaseSettings.ENABLE_DEBUG_BUFFER_LOGGING.get()) {
+ builder.append("\nBufferStrings: ");
+ findAsciiStrings(builder, protoBuffer);
+ }
+
+ return builder.toString();
+ }
+
+ /**
+ * Search through a byte array for all ASCII strings.
+ */
+ private static void findAsciiStrings(StringBuilder builder, byte[] buffer) {
+ // Valid ASCII values (ignore control characters).
+ final int minimumAscii = 32; // 32 = space character
+ final int maximumAscii = 126; // 127 = delete character
+ final int minimumAsciiStringLength = 4; // Minimum length of an ASCII string to include.
+ String delimitingCharacter = "❙"; // Non ascii character, to allow easier log filtering.
+
+ final int length = buffer.length;
+ int start = 0;
+ int end = 0;
+ while (end < length) {
+ int value = buffer[end];
+ if (value < minimumAscii || value > maximumAscii || end == length - 1) {
+ if (end - start >= minimumAsciiStringLength) {
+ for (int i = start; i < end; i++) {
+ builder.append((char) buffer[i]);
+ }
+ builder.append(delimitingCharacter);
+ }
+ start = end + 1;
+ }
+ end++;
+ }
+ }
+ }
+
+ private static final Filter[] filters = new Filter[]{
+ new DummyFilter() // Replaced by patch.
+ };
+
+ private static final StringTrieSearch pathSearchTree = new StringTrieSearch();
+ private static final StringTrieSearch identifierSearchTree = new StringTrieSearch();
+ private static final StringTrieSearch allValueSearchTree = new StringTrieSearch();
+
+ private static final byte[] EMPTY_BYTE_ARRAY = new byte[0];
+
+ /**
+ * Because litho filtering is multi-threaded and the buffer is passed in from a different injection point,
+ * the buffer is saved to a ThreadLocal so each calling thread does not interfere with other threads.
+ */
+ private static final ThreadLocal bufferThreadLocal = new ThreadLocal<>();
+
+ static {
+ for (Filter filter : filters) {
+ filterUsingCallbacks(identifierSearchTree, filter,
+ filter.identifierCallbacks, Filter.FilterContentType.IDENTIFIER);
+ filterUsingCallbacks(pathSearchTree, filter,
+ filter.pathCallbacks, Filter.FilterContentType.PATH);
+ filterUsingCallbacks(allValueSearchTree, filter,
+ filter.allValueCallbacks, Filter.FilterContentType.ALLVALUE);
+ }
+
+ Logger.printDebug(() -> "Using: "
+ + identifierSearchTree.numberOfPatterns() + " identifier filters"
+ + " (" + identifierSearchTree.getEstimatedMemorySize() + " KB), "
+ + pathSearchTree.numberOfPatterns() + " path filters"
+ + " (" + pathSearchTree.getEstimatedMemorySize() + " KB)");
+ }
+
+ private static void filterUsingCallbacks(StringTrieSearch pathSearchTree,
+ Filter filter, List groups,
+ Filter.FilterContentType type) {
+ for (StringFilterGroup group : groups) {
+ if (!group.includeInSearch()) {
+ continue;
+ }
+ for (String pattern : group.filters) {
+ pathSearchTree.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
+ if (!group.isEnabled()) return false;
+ LithoFilterParameters parameters = (LithoFilterParameters) callbackParameter;
+ return filter.isFiltered(parameters.path, parameters.identifier, parameters.allValue, parameters.protoBuffer,
+ group, type, matchedStartIndex);
+ }
+ );
+ }
+ }
+ }
+
+ /**
+ * Injection point. Called off the main thread.
+ */
+ public static void setProtoBuffer(@Nullable ByteBuffer protobufBuffer) {
+ // Set the buffer to a thread local. The buffer will remain in memory, even after the call to #filter completes.
+ // This is intentional, as it appears the buffer can be set once and then filtered multiple times.
+ // The buffer will be cleared from memory after a new buffer is set by the same thread,
+ // or when the calling thread eventually dies.
+ bufferThreadLocal.set(protobufBuffer);
+ }
+
+ /**
+ * Injection point. Called off the main thread, and commonly called by multiple threads at the same time.
+ */
+ public static boolean filter(@NonNull StringBuilder pathBuilder, @Nullable String identifier, @NonNull Object object) {
+ try {
+ if (pathBuilder.length() == 0) {
+ return false;
+ }
+
+ ByteBuffer protobufBuffer = bufferThreadLocal.get();
+ final byte[] bufferArray;
+ // Potentially the buffer may have been null or never set up until now.
+ // Use an empty buffer so the litho id or path filters still work correctly.
+ if (protobufBuffer == null) {
+ Logger.printDebug(() -> "Proto buffer is null, using an empty buffer array");
+ bufferArray = EMPTY_BYTE_ARRAY;
+ } else if (!protobufBuffer.hasArray()) {
+ Logger.printDebug(() -> "Proto buffer does not have an array, using an empty buffer array");
+ bufferArray = EMPTY_BYTE_ARRAY;
+ } else {
+ bufferArray = protobufBuffer.array();
+ }
+
+ LithoFilterParameters parameter = new LithoFilterParameters(pathBuilder.toString(), identifier,
+ object.toString(), bufferArray);
+ Logger.printDebug(() -> "Searching " + parameter);
+
+ if (parameter.identifier != null && identifierSearchTree.matches(parameter.identifier, parameter)) {
+ return true;
+ }
+
+ if (pathSearchTree.matches(parameter.path, parameter)) {
+ return true;
+ }
+
+ if (allValueSearchTree.matches(parameter.allValue, parameter)) {
+ return true;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "Litho filter failure", ex);
+ }
+
+ return false;
+ }
+}
+
+/**
+ * Placeholder for actual filters.
+ */
+final class DummyFilter extends Filter {
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/StringFilterGroup.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/StringFilterGroup.java
new file mode 100644
index 0000000000..9ac111cf9c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/StringFilterGroup.java
@@ -0,0 +1,29 @@
+package app.revanced.extension.shared.patches.components;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+
+public class StringFilterGroup extends FilterGroup {
+
+ public StringFilterGroup(final BooleanSetting setting, final String... filters) {
+ super(setting, filters);
+ }
+
+ @Override
+ public FilterGroupResult check(final String string) {
+ int matchedIndex = -1;
+ int matchedLength = 0;
+ if (isEnabled()) {
+ for (String pattern : filters) {
+ if (!string.isEmpty()) {
+ final int indexOf = string.indexOf(pattern);
+ if (indexOf >= 0) {
+ matchedIndex = indexOf;
+ matchedLength = pattern.length();
+ break;
+ }
+ }
+ }
+ }
+ return new FilterGroupResult(setting, matchedIndex, matchedLength);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/StringFilterGroupList.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/StringFilterGroupList.java
new file mode 100644
index 0000000000..ae6c189e93
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/components/StringFilterGroupList.java
@@ -0,0 +1,9 @@
+package app.revanced.extension.shared.patches.components;
+
+import app.revanced.extension.shared.utils.StringTrieSearch;
+
+public final class StringFilterGroupList extends FilterGroupList {
+ protected StringTrieSearch createSearchGraph() {
+ return new StringTrieSearch();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/Filter.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/Filter.java
new file mode 100644
index 0000000000..e22d73f521
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/Filter.java
@@ -0,0 +1,70 @@
+package app.revanced.extension.shared.patches.spans;
+
+import android.graphics.Color;
+import android.graphics.drawable.ColorDrawable;
+import android.graphics.drawable.Drawable;
+import android.text.SpannableString;
+import android.text.style.ImageSpan;
+import android.text.style.RelativeSizeSpan;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.utils.Logger;
+
+/**
+ * Filters litho based components.
+ *
+ * All callbacks must be registered before the constructor completes.
+ */
+public abstract class Filter {
+ private static final RelativeSizeSpan relativeSizeSpanDummy = new RelativeSizeSpan(0f);
+ private static final Drawable transparentDrawable = new ColorDrawable(Color.TRANSPARENT);
+ private static final ImageSpan imageSpanDummy = new ImageSpan(transparentDrawable);
+
+ /**
+ * Path callbacks. Do not add to this instance,
+ * and instead use {@link #addCallbacks(StringFilterGroup...)}.
+ */
+ protected final List callbacks = new ArrayList<>();
+
+ /**
+ * Adds callbacks to {@link #skip(String, SpannableString , Object, int, int, int, boolean, SpanType, StringFilterGroup)}
+ * if any of the groups are found.
+ */
+ protected final void addCallbacks(StringFilterGroup... groups) {
+ callbacks.addAll(Arrays.asList(groups));
+ }
+
+ protected final void hideSpan(SpannableString spannableString, int start, int end, int flags) {
+ spannableString.setSpan(relativeSizeSpanDummy, start, end, flags);
+ }
+
+ protected final void hideImageSpan(SpannableString spannableString, int start, int end, int flags) {
+ spannableString.setSpan(imageSpanDummy, start, end, flags);
+ }
+
+ /**
+ * Called after an enabled filter has been matched.
+ * Default implementation is to always filter the matched component and log the action.
+ * Subclasses can perform additional or different checks if needed.
+ *
+ * If the content is to be filtered, subclasses should always
+ * call this method (and never return a plain 'true').
+ * That way the logs will always show when a component was filtered and which filter hide it.
+ *
+ * Method is called off the main thread.
+ *
+ * @param matchedGroup The actual filter that matched.
+ */
+ public boolean skip(String conversionContext, SpannableString spannableString, Object span, int start, int end,
+ int flags, boolean isWord, SpanType spanType, StringFilterGroup matchedGroup) {
+ if (BaseSettings.ENABLE_DEBUG_LOGGING.get()) {
+ String filterSimpleName = getClass().getSimpleName();
+ Logger.printDebug(() -> filterSimpleName + " Removed setSpan: " + spanType.type);
+ }
+ return true;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/FilterGroup.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/FilterGroup.java
new file mode 100644
index 0000000000..d1dc3c2a07
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/FilterGroup.java
@@ -0,0 +1,78 @@
+package app.revanced.extension.shared.patches.spans;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+
+public abstract class FilterGroup {
+ final static class FilterGroupResult {
+ private BooleanSetting setting;
+ private int matchedIndex;
+ // In the future it might be useful to include which pattern matched,
+ // but for now that is not needed.
+
+ FilterGroupResult() {
+ this(null, -1);
+ }
+
+ FilterGroupResult(BooleanSetting setting, int matchedIndex) {
+ setValues(setting, matchedIndex);
+ }
+
+ public void setValues(BooleanSetting setting, int matchedIndex) {
+ this.setting = setting;
+ this.matchedIndex = matchedIndex;
+ }
+
+ /**
+ * A null value if the group has no setting,
+ * or if no match is returned from {@link FilterGroupList#check(Object)}.
+ */
+ public BooleanSetting getSetting() {
+ return setting;
+ }
+
+ public boolean isFiltered() {
+ return matchedIndex >= 0;
+ }
+ }
+
+ protected final BooleanSetting setting;
+ protected final T[] filters;
+
+ /**
+ * Initialize a new filter group.
+ *
+ * @param setting The associated setting.
+ * @param filters The filters.
+ */
+ @SafeVarargs
+ public FilterGroup(final BooleanSetting setting, final T... filters) {
+ this.setting = setting;
+ this.filters = filters;
+ if (filters.length == 0) {
+ throw new IllegalArgumentException("Must use one or more filter patterns (zero specified)");
+ }
+ }
+
+ public boolean isEnabled() {
+ return setting == null || setting.get();
+ }
+
+ /**
+ * @return If {@link FilterGroupList} should include this group when searching.
+ * By default, all filters are included except non enabled settings that require reboot.
+ */
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ public boolean includeInSearch() {
+ return isEnabled() || !setting.rebootApp;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + ": " + (setting == null ? "(null setting)" : setting);
+ }
+
+ public abstract FilterGroupResult check(final T stack);
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/FilterGroupList.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/FilterGroupList.java
new file mode 100644
index 0000000000..16c82cf61c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/FilterGroupList.java
@@ -0,0 +1,65 @@
+package app.revanced.extension.shared.patches.spans;
+
+import androidx.annotation.NonNull;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Spliterator;
+import java.util.function.Consumer;
+
+import app.revanced.extension.shared.utils.TrieSearch;
+
+public abstract class FilterGroupList> implements Iterable {
+
+ private final List filterGroups = new ArrayList<>();
+ private final TrieSearch search = createSearchGraph();
+
+ @SafeVarargs
+ protected final void addAll(final T... groups) {
+ filterGroups.addAll(Arrays.asList(groups));
+
+ for (T group : groups) {
+ if (!group.includeInSearch()) {
+ continue;
+ }
+ for (V pattern : group.filters) {
+ search.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
+ if (group.isEnabled()) {
+ FilterGroup.FilterGroupResult result = (FilterGroup.FilterGroupResult) callbackParameter;
+ result.setValues(group.setting, matchedStartIndex);
+ return true;
+ }
+ return false;
+ });
+ }
+ }
+ }
+
+ @NonNull
+ @Override
+ public Iterator iterator() {
+ return filterGroups.iterator();
+ }
+
+ @Override
+ public void forEach(@NonNull Consumer super T> action) {
+ filterGroups.forEach(action);
+ }
+
+ @NonNull
+ @Override
+ public Spliterator spliterator() {
+ return filterGroups.spliterator();
+ }
+
+ protected FilterGroup.FilterGroupResult check(V stack) {
+ FilterGroup.FilterGroupResult result = new FilterGroup.FilterGroupResult();
+ search.matches(stack, result);
+ return result;
+
+ }
+
+ protected abstract TrieSearch createSearchGraph();
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/InclusiveSpanPatch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/InclusiveSpanPatch.java
new file mode 100644
index 0000000000..f82bcfe877
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/InclusiveSpanPatch.java
@@ -0,0 +1,201 @@
+package app.revanced.extension.shared.patches.spans;
+
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.style.AbsoluteSizeSpan;
+import android.text.style.CharacterStyle;
+import android.text.style.ClickableSpan;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.ImageSpan;
+import android.text.style.TypefaceSpan;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.StringTrieSearch;
+
+
+/**
+ * Placeholder for actual filters.
+ */
+final class DummyFilter extends Filter { }
+
+@SuppressWarnings("unused")
+public final class InclusiveSpanPatch {
+ private static final BooleanSetting ENABLE_DEBUG_LOGGING = BaseSettings.ENABLE_DEBUG_LOGGING;
+
+ /**
+ * Simple wrapper to pass the litho parameters through the prefix search.
+ */
+ private static final class LithoFilterParameters {
+ final String conversionContext;
+ final SpannableString spannableString;
+ final Object span;
+ final int start;
+ final int end;
+ final int flags;
+ final String originalString;
+ final int originalLength;
+ final SpanType spanType;
+ final boolean isWord;
+
+ public LithoFilterParameters(String conversionContext, SpannableString spannableString,
+ Object span, int start, int end, int flags) {
+ this.conversionContext = conversionContext;
+ this.spannableString = spannableString;
+ this.span = span;
+ this.start = start;
+ this.end = end;
+ this.flags = flags;
+ this.originalString = spannableString.toString();
+ this.originalLength = spannableString.length();
+ this.spanType = getSpanType(span);
+ this.isWord = !(start == 0 && end == originalLength);
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("CharSequence:'")
+ .append(originalString)
+ .append("'\nSpanType:'")
+ .append(getSpanType(spanType, span))
+ .append("'\nLength:'")
+ .append(originalLength)
+ .append("'\nStart:'")
+ .append(start)
+ .append("'\nEnd:'")
+ .append(end)
+ .append("'\nisWord:'")
+ .append(isWord)
+ .append("'");
+ if (isWord) {
+ builder.append("\nWord:'")
+ .append(originalString.substring(start, end))
+ .append("'");
+ }
+ return builder.toString();
+ }
+ }
+
+ private static SpanType getSpanType(Object span) {
+ if (span instanceof ClickableSpan) {
+ return SpanType.CLICKABLE;
+ } else if (span instanceof ForegroundColorSpan) {
+ return SpanType.FOREGROUND_COLOR;
+ } else if (span instanceof AbsoluteSizeSpan) {
+ return SpanType.ABSOLUTE_SIZE;
+ } else if (span instanceof TypefaceSpan) {
+ return SpanType.TYPEFACE;
+ } else if (span instanceof ImageSpan) {
+ return SpanType.IMAGE;
+ } else if (span instanceof CharacterStyle) { // Replaced by patch.
+ return SpanType.CUSTOM_CHARACTER_STYLE;
+ } else {
+ return SpanType.UNKNOWN;
+ }
+ }
+
+ private static String getSpanType(SpanType spanType, Object span) {
+ return spanType == SpanType.UNKNOWN
+ ? span.getClass().getSimpleName()
+ : spanType.type;
+ }
+
+ private static final Filter[] filters = new Filter[] {
+ new DummyFilter() // Replaced by patch.
+ };
+
+ private static final StringTrieSearch searchTree = new StringTrieSearch();
+
+
+ /**
+ * Because litho filtering is multi-threaded and the buffer is passed in from a different injection point,
+ * the buffer is saved to a ThreadLocal so each calling thread does not interfere with other threads.
+ */
+ private static final ThreadLocal conversionContextThreadLocal = new ThreadLocal<>();
+
+ static {
+ for (Filter filter : filters) {
+ filterUsingCallbacks(filter, filter.callbacks);
+ }
+
+ if (ENABLE_DEBUG_LOGGING.get()) {
+ Logger.printDebug(() -> "Using: "
+ + searchTree.numberOfPatterns() + " conversion context filters"
+ + " (" + searchTree.getEstimatedMemorySize() + " KB)");
+ }
+ }
+
+ private static void filterUsingCallbacks(Filter filter, List groups) {
+ for (StringFilterGroup group : groups) {
+ if (!group.includeInSearch()) {
+ continue;
+ }
+ for (String pattern : group.filters) {
+ InclusiveSpanPatch.searchTree.addPattern(pattern, (textSearched, matchedStartIndex, matchedLength, callbackParameter) -> {
+ if (!group.isEnabled()) return false;
+ LithoFilterParameters parameters = (LithoFilterParameters) callbackParameter;
+ return filter.skip(parameters.conversionContext, parameters.spannableString, parameters.span,
+ parameters.start, parameters.end, parameters.flags, parameters.isWord, parameters.spanType, group);
+ }
+ );
+ }
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param conversionContext ConversionContext is used to identify whether it is a comment thread or not.
+ */
+ public static CharSequence setConversionContext(@NonNull Object conversionContext,
+ @NonNull CharSequence original) {
+ conversionContextThreadLocal.set(conversionContext.toString());
+ return original;
+ }
+
+ private static boolean returnEarly(SpannableString spannableString, Object span, int start, int end, int flags) {
+ try {
+ final String conversionContext = conversionContextThreadLocal.get();
+ if (conversionContext == null || conversionContext.isEmpty()) {
+ return false;
+ }
+
+ LithoFilterParameters parameter =
+ new LithoFilterParameters(conversionContext, spannableString, span, start, end, flags);
+
+ if (ENABLE_DEBUG_LOGGING.get()) {
+ Logger.printDebug(() -> "Searching...\n\u200B\n" + parameter);
+ }
+
+ return searchTree.matches(parameter.conversionContext, parameter);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Spans filter failure", ex);
+ }
+
+ return false;
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param spannableString Original SpannableString.
+ * @param span Span such as {@link ClickableSpan}, {@link ForegroundColorSpan},
+ * {@link AbsoluteSizeSpan}, {@link TypefaceSpan}, {@link ImageSpan}.
+ * @param start Start index of {@link Spannable#setSpan(Object, int, int, int)}.
+ * @param end End index of {@link Spannable#setSpan(Object, int, int, int)}.
+ * @param flags Flags of {@link Spannable#setSpan(Object, int, int, int)}.
+ */
+ public static void setSpan(SpannableString spannableString, Object span, int start, int end, int flags) {
+ if (returnEarly(spannableString, span, start, end, flags)) {
+ return;
+ }
+ spannableString.setSpan(span, start, end, flags);
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/SpanType.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/SpanType.java
new file mode 100644
index 0000000000..0ba705410c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/SpanType.java
@@ -0,0 +1,20 @@
+package app.revanced.extension.shared.patches.spans;
+
+import androidx.annotation.NonNull;
+
+public enum SpanType {
+ CLICKABLE("ClickableSpan"),
+ FOREGROUND_COLOR("ForegroundColorSpan"),
+ ABSOLUTE_SIZE("AbsoluteSizeSpan"),
+ TYPEFACE("TypefaceSpan"),
+ IMAGE("ImageSpan"),
+ CUSTOM_CHARACTER_STYLE("CustomCharacterStyle"),
+ UNKNOWN("Unknown");
+
+ @NonNull
+ public final String type;
+
+ SpanType(@NonNull String type) {
+ this.type = type;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/StringFilterGroup.java b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/StringFilterGroup.java
new file mode 100644
index 0000000000..3841533181
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/patches/spans/StringFilterGroup.java
@@ -0,0 +1,27 @@
+package app.revanced.extension.shared.patches.spans;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+
+public class StringFilterGroup extends FilterGroup {
+
+ public StringFilterGroup(final BooleanSetting setting, final String... filters) {
+ super(setting, filters);
+ }
+
+ @Override
+ public FilterGroupResult check(final String string) {
+ int matchedIndex = -1;
+ if (isEnabled()) {
+ for (String pattern : filters) {
+ if (!string.isEmpty()) {
+ final int indexOf = string.indexOf(pattern);
+ if (indexOf >= 0) {
+ matchedIndex = indexOf;
+ break;
+ }
+ }
+ }
+ }
+ return new FilterGroupResult(setting, matchedIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/requests/Requester.java b/extensions/shared/src/main/java/app/revanced/extension/shared/requests/Requester.java
new file mode 100644
index 0000000000..8ab950f256
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/requests/Requester.java
@@ -0,0 +1,142 @@
+package app.revanced.extension.shared.requests;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+
+@SuppressWarnings("unused")
+public class Requester {
+ public Requester() {
+ }
+
+ public static HttpURLConnection getConnectionFromRoute(String apiUrl, Route route, String... params) throws IOException {
+ return getConnectionFromCompiledRoute(apiUrl, route.compile(params));
+ }
+
+ public static HttpURLConnection getConnectionFromCompiledRoute(String apiUrl, Route.CompiledRoute route) throws IOException {
+ String url = apiUrl + route.getCompiledRoute();
+ HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
+ // Request data is in the URL parameters and no body is sent.
+ // The calling code must set a length if using a request body.
+ connection.setFixedLengthStreamingMode(0);
+ connection.setRequestMethod(route.getMethod().name());
+ connection.setRequestProperty("User-Agent", System.getProperty("http.agent") + ";");
+
+ return connection;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, and closes the underlying InputStream.
+ */
+ private static String parseInputStreamAndClose(InputStream inputStream) throws IOException {
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream))) {
+ StringBuilder jsonBuilder = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) {
+ jsonBuilder.append(line);
+ jsonBuilder.append('\n');
+ }
+ return jsonBuilder.toString();
+ }
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} response as a String.
+ * This does not close the url connection. If further requests to this host are unlikely
+ * in the near future, then instead use {@link #parseStringAndDisconnect(HttpURLConnection)}.
+ */
+ public static String parseString(HttpURLConnection connection) throws IOException {
+ return parseInputStreamAndClose(connection.getInputStream());
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} response as a String, and disconnect.
+ *
+ * Should only be used if other requests to the server in the near future are unlikely
+ *
+ * @see #parseString(HttpURLConnection)
+ */
+ public static String parseStringAndDisconnect(HttpURLConnection connection) throws IOException {
+ String result = parseString(connection);
+ connection.disconnect();
+ return result;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} error stream as a String.
+ * If the server sent no error response data, this returns an empty string.
+ */
+ public static String parseErrorString(HttpURLConnection connection) throws IOException {
+ InputStream errorStream = connection.getErrorStream();
+ if (errorStream == null) {
+ return "";
+ }
+ return parseInputStreamAndClose(errorStream);
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} error stream as a String, and disconnect.
+ * If the server sent no error response data, this returns an empty string.
+ *
+ * Should only be used if other requests to the server are unlikely in the near future.
+ *
+ * @see #parseErrorString(HttpURLConnection)
+ */
+ public static String parseErrorStringAndDisconnect(HttpURLConnection connection) throws IOException {
+ String result = parseErrorString(connection);
+ connection.disconnect();
+ return result;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection} response into a JSONObject.
+ * This does not close the url connection. If further requests to this host are unlikely
+ * in the near future, then instead use {@link #parseJSONObjectAndDisconnect(HttpURLConnection)}.
+ */
+ public static JSONObject parseJSONObject(HttpURLConnection connection) throws JSONException, IOException {
+ return new JSONObject(parseString(connection));
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, close the underlying InputStream, and disconnect.
+ *
+ * Should only be used if other requests to the server in the near future are unlikely
+ *
+ * @see #parseJSONObject(HttpURLConnection)
+ */
+ public static JSONObject parseJSONObjectAndDisconnect(HttpURLConnection connection) throws JSONException, IOException {
+ JSONObject object = parseJSONObject(connection);
+ connection.disconnect();
+ return object;
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, and closes the underlying InputStream.
+ * This does not close the url connection. If further requests to this host are unlikely
+ * in the near future, then instead use {@link #parseJSONArrayAndDisconnect(HttpURLConnection)}.
+ */
+ public static JSONArray parseJSONArray(HttpURLConnection connection) throws JSONException, IOException {
+ return new JSONArray(parseString(connection));
+ }
+
+ /**
+ * Parse the {@link HttpURLConnection}, close the underlying InputStream, and disconnect.
+ *
+ * Should only be used if other requests to the server in the near future are unlikely
+ *
+ * @see #parseJSONArray(HttpURLConnection)
+ */
+ public static JSONArray parseJSONArrayAndDisconnect(HttpURLConnection connection) throws JSONException, IOException {
+ JSONArray array = parseJSONArray(connection);
+ connection.disconnect();
+ return array;
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/requests/Route.java b/extensions/shared/src/main/java/app/revanced/extension/shared/requests/Route.java
new file mode 100644
index 0000000000..9ce0c7654b
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/requests/Route.java
@@ -0,0 +1,66 @@
+package app.revanced.extension.shared.requests;
+
+public class Route {
+ private final String route;
+ private final Route.Method method;
+ private final int paramCount;
+
+ public Route(Route.Method method, String route) {
+ this.method = method;
+ this.route = route;
+ this.paramCount = countMatches(route, '{');
+
+ if (paramCount != countMatches(route, '}'))
+ throw new IllegalArgumentException("Not enough parameters");
+ }
+
+ public Route.Method getMethod() {
+ return method;
+ }
+
+ public Route.CompiledRoute compile(String... params) {
+ if (params.length != paramCount)
+ throw new IllegalArgumentException("Error compiling route [" + route + "], incorrect amount of parameters provided. " +
+ "Expected: " + paramCount + ", provided: " + params.length);
+
+ StringBuilder compiledRoute = new StringBuilder(route);
+ for (int i = 0; i < paramCount; i++) {
+ int paramStart = compiledRoute.indexOf("{");
+ int paramEnd = compiledRoute.indexOf("}");
+ compiledRoute.replace(paramStart, paramEnd + 1, params[i]);
+ }
+ return new Route.CompiledRoute(this, compiledRoute.toString());
+ }
+
+ private int countMatches(CharSequence seq, char c) {
+ int count = 0;
+ for (int i = 0; i < seq.length(); i++) {
+ if (seq.charAt(i) == c)
+ count++;
+ }
+ return count;
+ }
+
+ public enum Method {
+ GET,
+ POST
+ }
+
+ public static class CompiledRoute {
+ private final Route baseRoute;
+ private final String compiledRoute;
+
+ private CompiledRoute(Route baseRoute, String compiledRoute) {
+ this.baseRoute = baseRoute;
+ this.compiledRoute = compiledRoute;
+ }
+
+ public String getCompiledRoute() {
+ return compiledRoute;
+ }
+
+ public Route.Method getMethod() {
+ return baseRoute.method;
+ }
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/ReturnYouTubeDislike.java b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/ReturnYouTubeDislike.java
new file mode 100644
index 0000000000..c7031d02fb
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/ReturnYouTubeDislike.java
@@ -0,0 +1,17 @@
+package app.revanced.extension.shared.returnyoutubedislike;
+
+public class ReturnYouTubeDislike {
+
+ public enum Vote {
+ LIKE(1),
+ DISLIKE(-1),
+ LIKE_REMOVE(0);
+
+ public final int value;
+
+ Vote(int value) {
+ this.value = value;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/RYDVoteData.java b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/RYDVoteData.java
new file mode 100644
index 0000000000..a4a56de04f
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/RYDVoteData.java
@@ -0,0 +1,179 @@
+package app.revanced.extension.shared.returnyoutubedislike.requests;
+
+import static app.revanced.extension.shared.returnyoutubedislike.ReturnYouTubeDislike.Vote;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import app.revanced.extension.shared.utils.Logger;
+
+/**
+ * ReturnYouTubeDislike API estimated like/dislike/view counts.
+ *
+ * ReturnYouTubeDislike does not guarantee when the counts are updated.
+ * So these values may lag behind what YouTube shows.
+ */
+@SuppressWarnings("unused")
+public final class RYDVoteData {
+ @NonNull
+ public final String videoId;
+
+ /**
+ * Estimated number of views
+ */
+ public final long viewCount;
+
+ private final long fetchedLikeCount;
+ private volatile long likeCount; // Read/write from different threads.
+ /**
+ * Like count can be hidden by video creator, but RYD still tracks the number
+ * of like/dislikes it received thru it's browser extension and and API.
+ * The raw like/dislikes can be used to calculate a percentage.
+ *
+ * Raw values can be null, especially for older videos with little to no views.
+ */
+ @Nullable
+ private final Long fetchedRawLikeCount;
+ private volatile float likePercentage;
+
+ private final long fetchedDislikeCount;
+ private volatile long dislikeCount; // Read/write from different threads.
+ @Nullable
+ private final Long fetchedRawDislikeCount;
+ private volatile float dislikePercentage;
+
+ @Nullable
+ private static Long getLongIfExist(JSONObject json, String key) throws JSONException {
+ return json.isNull(key)
+ ? null
+ : json.getLong(key);
+ }
+
+ /**
+ * @throws JSONException if JSON parse error occurs, or if the values make no sense (ie: negative values)
+ */
+ public RYDVoteData(@NonNull JSONObject json) throws JSONException {
+ videoId = json.getString("id");
+ viewCount = json.getLong("viewCount");
+
+ fetchedLikeCount = json.getLong("likes");
+ fetchedRawLikeCount = getLongIfExist(json, "rawLikes");
+
+ fetchedDislikeCount = json.getLong("dislikes");
+ fetchedRawDislikeCount = getLongIfExist(json, "rawDislikes");
+
+ if (viewCount < 0 || fetchedLikeCount < 0 || fetchedDislikeCount < 0) {
+ throw new JSONException("Unexpected JSON values: " + json);
+ }
+ likeCount = fetchedLikeCount;
+ dislikeCount = fetchedDislikeCount;
+ updateUsingVote(Vote.LIKE_REMOVE); // Calculate percentages.
+ }
+
+ /**
+ * Public like count of the video, as reported by YT when RYD last updated it's data.
+ *
+ * If the likes were hidden by the video creator, then this returns an
+ * estimated likes using the same extrapolation as the dislikes.
+ */
+ public long getLikeCount() {
+ return likeCount;
+ }
+
+ /**
+ * Estimated total dislike count, extrapolated from the public like count using RYD data.
+ */
+ public long getDislikeCount() {
+ return dislikeCount;
+ }
+
+ /**
+ * Estimated percentage of likes for all votes. Value has range of [0, 1]
+ *
+ * A video with 400 positive votes, and 100 negative votes, has a likePercentage of 0.8
+ */
+ public float getLikePercentage() {
+ return likePercentage;
+ }
+
+ /**
+ * Estimated percentage of dislikes for all votes. Value has range of [0, 1]
+ *
+ * A video with 400 positive votes, and 100 negative votes, has a dislikePercentage of 0.2
+ */
+ public float getDislikePercentage() {
+ return dislikePercentage;
+ }
+
+ public void updateUsingVote(Vote vote) {
+ final int likesToAdd, dislikesToAdd;
+
+ switch (vote) {
+ case LIKE:
+ likesToAdd = 1;
+ dislikesToAdd = 0;
+ break;
+ case DISLIKE:
+ likesToAdd = 0;
+ dislikesToAdd = 1;
+ break;
+ case LIKE_REMOVE:
+ likesToAdd = 0;
+ dislikesToAdd = 0;
+ break;
+ default:
+ throw new IllegalStateException();
+ }
+
+ // If a video has no public likes but RYD has raw like data,
+ // then use the raw data instead.
+ final boolean videoHasNoPublicLikes = fetchedLikeCount == 0;
+ final boolean hasRawData = fetchedRawLikeCount != null && fetchedRawDislikeCount != null;
+
+ if (videoHasNoPublicLikes && hasRawData && fetchedRawDislikeCount > 0) {
+ // YT creator has hidden the likes count, and this is an older video that
+ // RYD does not provide estimated like counts.
+ //
+ // But we can calculate the public likes the same way RYD does for newer videos with hidden likes,
+ // by using the same raw to estimated scale factor applied to dislikes.
+ // This calculation exactly matches the public likes RYD provides for newer hidden videos.
+ final float estimatedRawScaleFactor = (float) fetchedDislikeCount / fetchedRawDislikeCount;
+ likeCount = (long) (estimatedRawScaleFactor * fetchedRawLikeCount) + likesToAdd;
+ Logger.printDebug(() -> "Using locally calculated estimated likes since RYD did not return an estimate");
+ } else {
+ likeCount = fetchedLikeCount + likesToAdd;
+ }
+ // RYD now always returns an estimated dislike count, even if the likes are hidden.
+ dislikeCount = fetchedDislikeCount + dislikesToAdd;
+
+ // Update percentages.
+
+ final float totalCount = likeCount + dislikeCount;
+ if (totalCount == 0) {
+ likePercentage = 0;
+ dislikePercentage = 0;
+ } else {
+ likePercentage = likeCount / totalCount;
+ dislikePercentage = dislikeCount / totalCount;
+ }
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "RYDVoteData{"
+ + "videoId=" + videoId
+ + ", viewCount=" + viewCount
+ + ", likeCount=" + likeCount
+ + ", dislikeCount=" + dislikeCount
+ + ", likePercentage=" + likePercentage
+ + ", dislikePercentage=" + dislikePercentage
+ + '}';
+ }
+
+ // equals and hashcode is not implemented (currently not needed)
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java
new file mode 100644
index 0000000000..df1e503b59
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/ReturnYouTubeDislikeApi.java
@@ -0,0 +1,476 @@
+package app.revanced.extension.shared.returnyoutubedislike.requests;
+
+import static app.revanced.extension.shared.returnyoutubedislike.requests.ReturnYouTubeDislikeRoutes.getRYDConnectionFromRoute;
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.util.Base64;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.ProtocolException;
+import java.net.SocketTimeoutException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Objects;
+
+import app.revanced.extension.shared.requests.Requester;
+import app.revanced.extension.shared.returnyoutubedislike.ReturnYouTubeDislike;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+public class ReturnYouTubeDislikeApi {
+ /**
+ * {@link #fetchVotes(String)} TCP connection timeout
+ */
+ private static final int API_GET_VOTES_TCP_TIMEOUT_MILLISECONDS = 2 * 1000; // 2 Seconds.
+
+ /**
+ * {@link #fetchVotes(String)} HTTP read timeout.
+ * To locally debug and force timeouts, change this to a very small number (ie: 100)
+ */
+ private static final int API_GET_VOTES_HTTP_TIMEOUT_MILLISECONDS = 4 * 1000; // 4 Seconds.
+
+ /**
+ * Default connection and response timeout for voting and registration.
+ *
+ * Voting and user registration runs in the background and has has no urgency
+ * so this can be a larger value.
+ */
+ private static final int API_REGISTER_VOTE_TIMEOUT_MILLISECONDS = 60 * 1000; // 60 Seconds.
+
+ /**
+ * Response code of a successful API call
+ */
+ private static final int HTTP_STATUS_CODE_SUCCESS = 200;
+
+ /**
+ * Indicates a client rate limit has been reached and the client must back off.
+ */
+ private static final int HTTP_STATUS_CODE_RATE_LIMIT = 429;
+
+ /**
+ * How long to wait until API calls are resumed, if the API requested a back off.
+ * No clear guideline of how long to wait until resuming.
+ */
+ private static final int BACKOFF_RATE_LIMIT_MILLISECONDS = 10 * 60 * 1000; // 10 Minutes.
+
+ /**
+ * How long to wait until API calls are resumed, if any connection error occurs.
+ */
+ private static final int BACKOFF_CONNECTION_ERROR_MILLISECONDS = 2 * 60 * 1000; // 2 Minutes.
+
+ /**
+ * If non zero, then the system time of when API calls can resume.
+ */
+ private static volatile long timeToResumeAPICalls; // must be volatile, since different threads read/write to this
+
+ /**
+ * If the last API getVotes call failed for any reason (including server requested rate limit).
+ * Used to prevent showing repeat connection toasts when the API is down.
+ */
+ private static volatile boolean lastApiCallFailed;
+
+ public static boolean toastOnConnectionError = false;
+
+ private ReturnYouTubeDislikeApi() {
+ } // utility class
+
+ /**
+ * Clears any backoff rate limits in effect.
+ * Should be called if RYD is turned on/off.
+ */
+ public static void resetRateLimits() {
+ if (lastApiCallFailed || timeToResumeAPICalls != 0) {
+ Logger.printDebug(() -> "Reset rate limit");
+ }
+ lastApiCallFailed = false;
+ timeToResumeAPICalls = 0;
+ }
+
+ /**
+ * @return True, if api rate limit is in effect.
+ */
+ private static boolean checkIfRateLimitInEffect(String apiEndPointName) {
+ if (timeToResumeAPICalls == 0) {
+ return false;
+ }
+ final long now = System.currentTimeMillis();
+ if (now > timeToResumeAPICalls) {
+ timeToResumeAPICalls = 0;
+ return false;
+ }
+ Logger.printDebug(() -> "Ignoring api call " + apiEndPointName + " as rate limit is in effect");
+ return true;
+ }
+
+ /**
+ * @return True, if a client rate limit was requested
+ */
+ private static boolean checkIfRateLimitWasHit(int httpResponseCode) {
+ return httpResponseCode == HTTP_STATUS_CODE_RATE_LIMIT;
+ }
+
+ private static void updateRateLimitAndStats(boolean connectionError, boolean rateLimitHit) {
+ if (connectionError && rateLimitHit) {
+ throw new IllegalArgumentException();
+ }
+ if (connectionError) {
+ timeToResumeAPICalls = System.currentTimeMillis() + BACKOFF_CONNECTION_ERROR_MILLISECONDS;
+ lastApiCallFailed = true;
+ } else if (rateLimitHit) {
+ Logger.printDebug(() -> "API rate limit was hit. Stopping API calls for the next "
+ + BACKOFF_RATE_LIMIT_MILLISECONDS + " seconds");
+ timeToResumeAPICalls = System.currentTimeMillis() + BACKOFF_RATE_LIMIT_MILLISECONDS;
+ if (!lastApiCallFailed && toastOnConnectionError) {
+ Utils.showToastLong(str("revanced_ryd_failure_client_rate_limit_requested"));
+ }
+ lastApiCallFailed = true;
+ } else {
+ lastApiCallFailed = false;
+ }
+ }
+
+ private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex) {
+ if (!lastApiCallFailed && toastOnConnectionError) {
+ Utils.showToastShort(toastMessage);
+ }
+ if (ex != null) {
+ Logger.printInfo(() -> toastMessage, ex);
+ }
+ }
+
+ /**
+ * @return NULL if fetch failed, or if a rate limit is in effect.
+ */
+ @Nullable
+ public static RYDVoteData fetchVotes(String videoId) {
+ Utils.verifyOffMainThread();
+ Objects.requireNonNull(videoId);
+
+ if (checkIfRateLimitInEffect("fetchVotes")) {
+ return null;
+ }
+ Logger.printDebug(() -> "Fetching votes for: " + videoId);
+
+ try {
+ HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.GET_DISLIKES, videoId);
+ // request headers, as per https://returnyoutubedislike.com/docs/fetching
+ // the documentation says to use 'Accept:text/html', but the RYD browser plugin uses 'Accept:application/json'
+ connection.setRequestProperty("Accept", "application/json");
+ connection.setRequestProperty("Connection", "keep-alive"); // keep-alive is on by default with http 1.1, but specify anyways
+ connection.setRequestProperty("Pragma", "no-cache");
+ connection.setRequestProperty("Cache-Control", "no-cache");
+ connection.setUseCaches(false);
+ connection.setConnectTimeout(API_GET_VOTES_TCP_TIMEOUT_MILLISECONDS); // timeout for TCP connection to server
+ connection.setReadTimeout(API_GET_VOTES_HTTP_TIMEOUT_MILLISECONDS); // timeout for server response
+
+ final int responseCode = connection.getResponseCode();
+ if (checkIfRateLimitWasHit(responseCode)) {
+ connection.disconnect(); // rate limit hit, should disconnect
+ updateRateLimitAndStats(false, true);
+ return null;
+ }
+
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ // Do not disconnect, the same server connection will likely be used again soon.
+ JSONObject json = Requester.parseJSONObject(connection);
+ try {
+ RYDVoteData votingData = new RYDVoteData(json);
+ updateRateLimitAndStats(false, false);
+ Logger.printDebug(() -> "Voting data fetched: " + votingData);
+ return votingData;
+ } catch (JSONException ex) {
+ Logger.printException(() -> "Failed to parse video: " + videoId + " json: " + json, ex);
+ // fall thru to update statistics
+ }
+ } else {
+ handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null);
+ }
+ connection.disconnect(); // something went wrong, might as well disconnect
+ } catch (
+ SocketTimeoutException ex) { // connection timed out, response timeout, or some other network error
+ handleConnectionError((str("revanced_ryd_failure_connection_timeout")), ex);
+ } catch (IOException ex) {
+ handleConnectionError((str("revanced_ryd_failure_generic", ex.getMessage())), ex);
+ } catch (Exception ex) {
+ // should never happen
+ Logger.printException(() -> "fetchVotes failure", ex);
+ }
+
+ updateRateLimitAndStats(true, false);
+ return null;
+ }
+
+ /**
+ * @return The newly created and registered user id. Returns NULL if registration failed.
+ */
+ @Nullable
+ public static String registerAsNewUser() {
+ Utils.verifyOffMainThread();
+ try {
+ if (checkIfRateLimitInEffect("registerAsNewUser")) {
+ return null;
+ }
+ String userId = randomString();
+ Logger.printDebug(() -> "Trying to register new user");
+
+ HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.GET_REGISTRATION, userId);
+ connection.setRequestProperty("Accept", "application/json");
+ connection.setConnectTimeout(API_REGISTER_VOTE_TIMEOUT_MILLISECONDS);
+ connection.setReadTimeout(API_REGISTER_VOTE_TIMEOUT_MILLISECONDS);
+
+ final int responseCode = connection.getResponseCode();
+ if (checkIfRateLimitWasHit(responseCode)) {
+ connection.disconnect(); // disconnect, as no more connections will be made for a little while
+ return null;
+ }
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ JSONObject json = Requester.parseJSONObject(connection);
+ String challenge = json.getString("challenge");
+ int difficulty = json.getInt("difficulty");
+
+ String solution = solvePuzzle(challenge, difficulty);
+ return confirmRegistration(userId, solution);
+ }
+ handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null);
+ connection.disconnect();
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex);
+ } catch (IOException ex) {
+ handleConnectionError(str("revanced_ryd_failure_generic", "registration failed"), ex);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to register user", ex); // should never happen
+ }
+ return null;
+ }
+
+ @Nullable
+ private static String confirmRegistration(String userId, String solution) {
+ Utils.verifyOffMainThread();
+ Objects.requireNonNull(userId);
+ Objects.requireNonNull(solution);
+ try {
+ if (checkIfRateLimitInEffect("confirmRegistration")) {
+ return null;
+ }
+ Logger.printDebug(() -> "Trying to confirm registration with solution: " + solution);
+
+ HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.CONFIRM_REGISTRATION, userId);
+ applyCommonPostRequestSettings(connection);
+
+ String jsonInputString = "{\"solution\": \"" + solution + "\"}";
+ byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8);
+ connection.setFixedLengthStreamingMode(body.length);
+ try (OutputStream os = connection.getOutputStream()) {
+ os.write(body);
+ }
+
+ final int responseCode = connection.getResponseCode();
+ if (checkIfRateLimitWasHit(responseCode)) {
+ connection.disconnect(); // disconnect, as no more connections will be made for a little while
+ return null;
+ }
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ Logger.printDebug(() -> "Registration confirmation successful");
+ return userId;
+ }
+ // Something went wrong, might as well disconnect.
+ String response = Requester.parseStringAndDisconnect(connection);
+ Logger.printInfo(() -> "Failed to confirm registration for user: " + userId
+ + " solution: " + solution + " responseCode: " + responseCode + " response: '" + response + "''");
+ handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null);
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex);
+ } catch (IOException ex) {
+ handleConnectionError(str("revanced_ryd_failure_generic", "confirm registration failed"), ex);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to confirm registration for user: " + userId
+ + "solution: " + solution, ex);
+ }
+ return null;
+ }
+
+ public static void sendVote(String userId, String videoId, ReturnYouTubeDislike.Vote vote) {
+ Utils.verifyOffMainThread();
+ Objects.requireNonNull(videoId);
+ Objects.requireNonNull(vote);
+
+ try {
+ if (userId == null) return;
+
+ if (checkIfRateLimitInEffect("sendVote")) {
+ return;
+ }
+ Logger.printDebug(() -> "Trying to vote for video: " + videoId + " with vote: " + vote);
+
+ HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.SEND_VOTE);
+ applyCommonPostRequestSettings(connection);
+
+ String voteJsonString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"value\": \"" + vote.value + "\"}";
+ byte[] body = voteJsonString.getBytes(StandardCharsets.UTF_8);
+ connection.setFixedLengthStreamingMode(body.length);
+ try (OutputStream os = connection.getOutputStream()) {
+ os.write(body);
+ }
+
+ final int responseCode = connection.getResponseCode();
+ if (checkIfRateLimitWasHit(responseCode)) {
+ connection.disconnect(); // disconnect, as no more connections will be made for a little while
+ return;
+ }
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ JSONObject json = Requester.parseJSONObject(connection);
+ String challenge = json.getString("challenge");
+ int difficulty = json.getInt("difficulty");
+
+ String solution = solvePuzzle(challenge, difficulty);
+ confirmVote(videoId, userId, solution);
+ return;
+ }
+
+ Logger.printInfo(() -> "Failed to send vote for video: " + videoId + " vote: " + vote
+ + " response code was: " + responseCode);
+ handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null);
+ connection.disconnect(); // something went wrong, might as well disconnect
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex);
+ } catch (IOException ex) {
+ handleConnectionError(str("revanced_ryd_failure_generic", "send vote failed"), ex);
+ } catch (Exception ex) {
+ // should never happen
+ Logger.printException(() -> "Failed to send vote for video: " + videoId + " vote: " + vote, ex);
+ }
+ }
+
+ private static void confirmVote(String videoId, String userId, String solution) {
+ Utils.verifyOffMainThread();
+ Objects.requireNonNull(videoId);
+ Objects.requireNonNull(userId);
+ Objects.requireNonNull(solution);
+
+ try {
+ if (checkIfRateLimitInEffect("confirmVote")) {
+ return;
+ }
+ Logger.printDebug(() -> "Trying to confirm vote for video: " + videoId + " solution: " + solution);
+ HttpURLConnection connection = getRYDConnectionFromRoute(ReturnYouTubeDislikeRoutes.CONFIRM_VOTE);
+ applyCommonPostRequestSettings(connection);
+
+ String jsonInputString = "{\"userId\": \"" + userId + "\", \"videoId\": \"" + videoId + "\", \"solution\": \"" + solution + "\"}";
+ byte[] body = jsonInputString.getBytes(StandardCharsets.UTF_8);
+ connection.setFixedLengthStreamingMode(body.length);
+ try (OutputStream os = connection.getOutputStream()) {
+ os.write(body);
+ }
+
+ final int responseCode = connection.getResponseCode();
+ if (checkIfRateLimitWasHit(responseCode)) {
+ connection.disconnect(); // disconnect, as no more connections will be made for a little while
+ return;
+ }
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ Logger.printDebug(() -> "Vote confirm successful for video: " + videoId);
+ return;
+ }
+ // Something went wrong, might as well disconnect.
+ String response = Requester.parseStringAndDisconnect(connection);
+ Logger.printInfo(() -> "Failed to confirm vote for video: " + videoId
+ + " solution: " + solution + " responseCode: " + responseCode + " response: '" + response + "'");
+ handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode), null);
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex);
+ } catch (IOException ex) {
+ handleConnectionError(str("revanced_ryd_failure_generic", "confirm vote failed"), ex);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to confirm vote for video: " + videoId
+ + " solution: " + solution, ex); // should never happen
+ }
+ }
+
+ private static void applyCommonPostRequestSettings(HttpURLConnection connection) throws ProtocolException {
+ connection.setRequestMethod("POST");
+ connection.setRequestProperty("Content-Type", "application/json");
+ connection.setRequestProperty("Accept", "application/json");
+ connection.setRequestProperty("Pragma", "no-cache");
+ connection.setRequestProperty("Cache-Control", "no-cache");
+ connection.setUseCaches(false);
+ connection.setDoOutput(true);
+ connection.setConnectTimeout(API_REGISTER_VOTE_TIMEOUT_MILLISECONDS); // timeout for TCP connection to server
+ connection.setReadTimeout(API_REGISTER_VOTE_TIMEOUT_MILLISECONDS); // timeout for server response
+ }
+
+
+ private static String solvePuzzle(String challenge, int difficulty) {
+ byte[] decodedChallenge = Base64.decode(challenge, Base64.NO_WRAP);
+
+ byte[] buffer = new byte[20];
+ System.arraycopy(decodedChallenge, 0, buffer, 4, 16);
+
+ MessageDigest md;
+ try {
+ md = MessageDigest.getInstance("SHA-512");
+ } catch (NoSuchAlgorithmException ex) {
+ throw new IllegalStateException(ex); // should never happen
+ }
+
+ final int maxCount = (int) (Math.pow(2, difficulty + 1) * 5);
+ for (int i = 0; i < maxCount; i++) {
+ buffer[0] = (byte) i;
+ buffer[1] = (byte) (i >> 8);
+ buffer[2] = (byte) (i >> 16);
+ buffer[3] = (byte) (i >> 24);
+ byte[] messageDigest = md.digest(buffer);
+
+ if (countLeadingZeroes(messageDigest) >= difficulty) {
+ return Base64.encodeToString(new byte[]{buffer[0], buffer[1], buffer[2], buffer[3]}, Base64.NO_WRAP);
+ }
+ }
+
+ // should never be reached
+ throw new IllegalStateException("Failed to solve puzzle challenge: " + challenge + " of difficulty: " + difficulty);
+ }
+
+ // https://stackoverflow.com/a/157202
+ private static String randomString() {
+ String AB = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ SecureRandom rnd = new SecureRandom();
+
+ StringBuilder sb = new StringBuilder(36);
+ for (int i = 0; i < 36; i++)
+ sb.append(AB.charAt(rnd.nextInt(AB.length())));
+ return sb.toString();
+ }
+
+ private static int countLeadingZeroes(byte[] uInt8View) {
+ int zeroes = 0;
+ int value;
+ for (byte b : uInt8View) {
+ value = b & 0xFF;
+ if (value == 0) {
+ zeroes += 8;
+ } else {
+ int count = 1;
+ if (value >>> 4 == 0) {
+ count += 4;
+ value <<= 4;
+ }
+ if (value >>> 6 == 0) {
+ count += 2;
+ value <<= 2;
+ }
+ zeroes += count - (value >>> 7);
+ break;
+ }
+ }
+ return zeroes;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java
new file mode 100644
index 0000000000..98c9fe6764
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubedislike/requests/ReturnYouTubeDislikeRoutes.java
@@ -0,0 +1,28 @@
+package app.revanced.extension.shared.returnyoutubedislike.requests;
+
+import static app.revanced.extension.shared.requests.Route.Method.GET;
+import static app.revanced.extension.shared.requests.Route.Method.POST;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+
+import app.revanced.extension.shared.requests.Requester;
+import app.revanced.extension.shared.requests.Route;
+
+public class ReturnYouTubeDislikeRoutes {
+ public static final String RYD_API_URL = "https://returnyoutubedislikeapi.com/";
+
+ public static final Route SEND_VOTE = new Route(POST, "interact/vote");
+ public static final Route CONFIRM_VOTE = new Route(POST, "interact/confirmVote");
+ public static final Route GET_DISLIKES = new Route(GET, "votes?videoId={video_id}");
+ public static final Route GET_REGISTRATION = new Route(GET, "puzzle/registration?userId={user_id}");
+ public static final Route CONFIRM_REGISTRATION = new Route(POST, "puzzle/registration?userId={user_id}");
+
+ public ReturnYouTubeDislikeRoutes() {
+ }
+
+ public static HttpURLConnection getRYDConnectionFromRoute(Route route, String... params) throws IOException {
+ return Requester.getConnectionFromRoute(RYD_API_URL, route, params);
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubeusername/requests/ChannelRequest.java b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubeusername/requests/ChannelRequest.java
new file mode 100644
index 0000000000..84e02755b6
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubeusername/requests/ChannelRequest.java
@@ -0,0 +1,167 @@
+package app.revanced.extension.shared.returnyoutubeusername.requests;
+
+import static java.lang.Boolean.TRUE;
+import static app.revanced.extension.shared.returnyoutubeusername.requests.ChannelRoutes.GET_CHANNEL_DETAILS;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import app.revanced.extension.shared.requests.Requester;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+public class ChannelRequest {
+ /**
+ * TCP connection and HTTP read timeout.
+ */
+ private static final int HTTP_TIMEOUT_MILLISECONDS = 3 * 1000;
+
+ /**
+ * Any arbitrarily large value, but must be at least twice {@link #HTTP_TIMEOUT_MILLISECONDS}
+ */
+ private static final int MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 6 * 1000;
+
+ @GuardedBy("itself")
+ private static final Map cache = Collections.synchronizedMap(
+ new LinkedHashMap<>(200) {
+ private static final int CACHE_LIMIT = 100;
+
+ @Override
+ protected boolean removeEldestEntry(Entry eldest) {
+ return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit.
+ }
+ });
+
+ public static void fetchRequestIfNeeded(@NonNull String handle, @NonNull String apiKey, Boolean userNameFirst) {
+ if (!cache.containsKey(handle)) {
+ cache.put(handle, new ChannelRequest(handle, apiKey, userNameFirst));
+ }
+ }
+
+ @Nullable
+ public static ChannelRequest getRequestForHandle(@NonNull String handle) {
+ return cache.get(handle);
+ }
+
+ private static void handleConnectionError(String toastMessage, @Nullable Exception ex) {
+ Logger.printInfo(() -> toastMessage, ex);
+ }
+
+ @Nullable
+ private static JSONObject send(String handle, String apiKey) {
+ Objects.requireNonNull(handle);
+ Objects.requireNonNull(apiKey);
+
+ final long startTime = System.currentTimeMillis();
+ Logger.printDebug(() -> "Fetching channel handle for: " + handle);
+
+ try {
+ HttpURLConnection connection = ChannelRoutes.getChannelConnectionFromRoute(GET_CHANNEL_DETAILS, handle, apiKey);
+ connection.setRequestProperty("Content-Type", "application/json");
+ connection.setRequestProperty("Connection", "keep-alive"); // keep-alive is on by default with http 1.1, but specify anyways
+ connection.setRequestProperty("Pragma", "no-cache");
+ connection.setRequestProperty("Cache-Control", "no-cache");
+ connection.setUseCaches(false);
+ connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS);
+ connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS);
+
+ final int responseCode = connection.getResponseCode();
+ if (responseCode == 200) return Requester.parseJSONObject(connection);
+
+ handleConnectionError("API not available with response code: "
+ + responseCode + " message: " + connection.getResponseMessage(),
+ null);
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError("Connection timeout", ex);
+ } catch (IOException ex) {
+ handleConnectionError("Network error", ex);
+ } catch (Exception ex) {
+ Logger.printException(() -> "send failed", ex);
+ } finally {
+ Logger.printDebug(() -> "handle: " + handle + " took: " + (System.currentTimeMillis() - startTime) + "ms");
+ }
+
+ return null;
+ }
+
+ private static String fetch(@NonNull String handle, @NonNull String apiKey, Boolean userNameFirst) {
+ final JSONObject channelJsonObject = send(handle, apiKey);
+ if (channelJsonObject != null) {
+ try {
+ final String userName = channelJsonObject
+ .getJSONArray("items")
+ .getJSONObject(0)
+ .getJSONObject("snippet")
+ .getString("title");
+ return authorBadgeBuilder(handle, userName, userNameFirst);
+ } catch (JSONException e) {
+ Logger.printDebug(() -> "Fetch failed while processing response data for response: " + channelJsonObject);
+ }
+ }
+ return null;
+ }
+
+ private static final String AUTHOR_BADGE_FORMAT = "\u202D%s\u2009%s";
+ private static final String PARENTHESES_FORMAT = "(%s)";
+
+ private static String authorBadgeBuilder(@NonNull String handle, @NonNull String userName, Boolean userNameFirst) {
+ if (userNameFirst == null) {
+ return userName;
+ } else if (TRUE.equals(userNameFirst)) {
+ handle = String.format(Locale.ENGLISH, PARENTHESES_FORMAT, handle);
+ if (!Utils.isRightToLeftTextLayout()) {
+ return String.format(Locale.ENGLISH, AUTHOR_BADGE_FORMAT, userName, handle);
+ }
+ } else {
+ userName = String.format(Locale.ENGLISH, PARENTHESES_FORMAT, userName);
+ }
+ return String.format(Locale.ENGLISH, AUTHOR_BADGE_FORMAT, handle, userName);
+ }
+
+ private final String handle;
+ private final Future future;
+
+ private ChannelRequest(String handle, String apiKey, Boolean append) {
+ this.handle = handle;
+ this.future = Utils.submitOnBackgroundThread(() -> fetch(handle, apiKey, append));
+ }
+
+ @Nullable
+ public String getStream() {
+ try {
+ return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ex) {
+ Logger.printInfo(() -> "getStream timed out", ex);
+ } catch (InterruptedException ex) {
+ Logger.printException(() -> "getStream interrupted", ex);
+ Thread.currentThread().interrupt(); // Restore interrupt status flag.
+ } catch (ExecutionException ex) {
+ Logger.printException(() -> "getStream failure", ex);
+ }
+
+ return null;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "ChannelRequest{" + "handle='" + handle + '\'' + '}';
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubeusername/requests/ChannelRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubeusername/requests/ChannelRoutes.java
new file mode 100644
index 0000000000..14da596034
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/returnyoutubeusername/requests/ChannelRoutes.java
@@ -0,0 +1,22 @@
+package app.revanced.extension.shared.returnyoutubeusername.requests;
+
+import static app.revanced.extension.shared.requests.Route.Method.GET;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+
+import app.revanced.extension.shared.requests.Requester;
+import app.revanced.extension.shared.requests.Route;
+
+public class ChannelRoutes {
+ public static final String YOUTUBEI_V3_GAPIS_URL = "https://www.googleapis.com/youtube/v3/";
+
+ public static final Route GET_CHANNEL_DETAILS = new Route(GET, "channels?part=snippet&forHandle={handle}&key={api_key}");
+
+ public ChannelRoutes() {
+ }
+
+ public static HttpURLConnection getChannelConnectionFromRoute(Route route, String... params) throws IOException {
+ return Requester.getConnectionFromRoute(YOUTUBEI_V3_GAPIS_URL, route, params);
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java
new file mode 100644
index 0000000000..c75290bd29
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BaseSettings.java
@@ -0,0 +1,44 @@
+package app.revanced.extension.shared.settings;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+
+import app.revanced.extension.shared.patches.ReturnYouTubeUsernamePatch.DisplayFormat;
+
+/**
+ * Settings shared across multiple apps.
+ *
+ * To ensure this class is loaded when the UI is created, app specific setting bundles should extend
+ * or reference this class.
+ */
+public class BaseSettings {
+ public static final BooleanSetting ENABLE_DEBUG_LOGGING = new BooleanSetting("revanced_enable_debug_logging", FALSE);
+ /**
+ * When enabled, share the debug logs with care.
+ * The buffer contains select user data, including the client ip address and information that could identify the end user.
+ */
+ public static final BooleanSetting ENABLE_DEBUG_BUFFER_LOGGING = new BooleanSetting("revanced_enable_debug_buffer_logging", FALSE);
+ public static final BooleanSetting SETTINGS_INITIALIZED = new BooleanSetting("revanced_settings_initialized", FALSE, false, false);
+
+ /**
+ * These settings are used by YouTube and YouTube Music.
+ */
+ public static final BooleanSetting HIDE_FULLSCREEN_ADS = new BooleanSetting("revanced_hide_fullscreen_ads", TRUE, true);
+ public static final BooleanSetting HIDE_PROMOTION_ALERT_BANNER = new BooleanSetting("revanced_hide_promotion_alert_banner", TRUE);
+
+ public static final BooleanSetting DISABLE_AUTO_CAPTIONS = new BooleanSetting("revanced_disable_auto_captions", FALSE, true);
+
+ public static final BooleanSetting BYPASS_IMAGE_REGION_RESTRICTIONS = new BooleanSetting("revanced_bypass_image_region_restrictions", FALSE, true);
+ public static final BooleanSetting RETURN_YOUTUBE_USERNAME_ENABLED = new BooleanSetting("revanced_return_youtube_username_enabled", FALSE, true);
+ public static final EnumSetting RETURN_YOUTUBE_USERNAME_DISPLAY_FORMAT = new EnumSetting<>("revanced_return_youtube_username_display_format", DisplayFormat.USERNAME_ONLY, true);
+ public static final StringSetting RETURN_YOUTUBE_USERNAME_YOUTUBE_DATA_API_V3_DEVELOPER_KEY = new StringSetting("revanced_return_youtube_username_youtube_data_api_v3_developer_key", "", true, false);
+
+ /**
+ * @noinspection DeprecatedIsStillUsed
+ */
+ @Deprecated
+ // The official ReVanced does not offer this, so it has been removed from the settings only. Users can still access settings through import / export settings.
+ public static final StringSetting BYPASS_IMAGE_REGION_RESTRICTIONS_DOMAIN = new StringSetting("revanced_bypass_image_region_restrictions_domain", "yt4.ggpht.com", true);
+
+ public static final BooleanSetting SANITIZE_SHARING_LINKS = new BooleanSetting("revanced_sanitize_sharing_links", TRUE, true);
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BooleanSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BooleanSetting.java
new file mode 100644
index 0000000000..b517924b47
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/BooleanSetting.java
@@ -0,0 +1,93 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class BooleanSetting extends Setting {
+ public BooleanSetting(String key, Boolean defaultValue) {
+ super(key, defaultValue);
+ }
+
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+
+ public BooleanSetting(String key, Boolean defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+
+ public BooleanSetting(String key, Boolean defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+
+ public BooleanSetting(String key, Boolean defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+
+ public BooleanSetting(@NonNull String key, @NonNull Boolean defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ /**
+ * Sets, but does _not_ persistently save the value.
+ * This method is only to be used by the Settings preference code.
+ *
+ * This intentionally is a static method to deter
+ * accidental usage when {@link #save(Boolean)} was intnded.
+ */
+ public static void privateSetValue(@NonNull BooleanSetting setting, @NonNull Boolean newValue) {
+ setting.value = Objects.requireNonNull(newValue);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getBoolean(key, defaultValue);
+ }
+
+ @Override
+ protected Boolean readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return json.getBoolean(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Boolean.valueOf(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void save(@NonNull Boolean newValue) {
+ // Must set before saving to preferences (otherwise importing fails to update UI correctly).
+ value = Objects.requireNonNull(newValue);
+ preferences.saveBoolean(key, newValue);
+ }
+
+ @Override
+ public void saveValueFromString(@NonNull String newValue) {
+ setValueFromString(newValue);
+ preferences.saveString(key, newValue);
+ }
+
+ @NonNull
+ @Override
+ public Boolean get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/EnumSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/EnumSetting.java
new file mode 100644
index 0000000000..36c6ebfb75
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/EnumSetting.java
@@ -0,0 +1,131 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Locale;
+import java.util.Objects;
+
+import app.revanced.extension.shared.utils.Logger;
+
+/**
+ * If an Enum value is removed or changed, any saved or imported data using the
+ * non-existent value will be reverted to the default value
+ * (the event is logged, but no user error is displayed).
+ *
+ * All saved JSON text is converted to lowercase to keep the output less obnoxious.
+ */
+@SuppressWarnings("unused")
+public class EnumSetting> extends Setting {
+ public EnumSetting(String key, T defaultValue) {
+ super(key, defaultValue);
+ }
+
+ public EnumSetting(String key, T defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+
+ public EnumSetting(String key, T defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+
+ public EnumSetting(String key, T defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+
+ public EnumSetting(String key, T defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+
+ public EnumSetting(String key, T defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+
+ public EnumSetting(String key, T defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+
+ public EnumSetting(String key, T defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+
+ public EnumSetting(@NonNull String key, @NonNull T defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getEnum(key, defaultValue);
+ }
+
+ @Override
+ protected T readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ String enumName = json.getString(importExportKey);
+ try {
+ return getEnumFromString(enumName);
+ } catch (IllegalArgumentException ex) {
+ // Info level to allow removing enum values in the future without showing any user errors.
+ Logger.printInfo(() -> "Using default, and ignoring unknown enum value: " + enumName, ex);
+ return defaultValue;
+ }
+ }
+
+ @Override
+ protected void writeToJSON(JSONObject json, String importExportKey) throws JSONException {
+ // Use lowercase to keep the output less ugly.
+ json.put(importExportKey, value.name().toLowerCase(Locale.ENGLISH));
+ }
+
+ @NonNull
+ private T getEnumFromString(String enumName) {
+ //noinspection ConstantConditions
+ for (Enum> value : defaultValue.getClass().getEnumConstants()) {
+ if (value.name().equalsIgnoreCase(enumName)) {
+ // noinspection unchecked
+ return (T) value;
+ }
+ }
+ throw new IllegalArgumentException("Unknown enum value: " + enumName);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = getEnumFromString(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void save(@NonNull T newValue) {
+ // Must set before saving to preferences (otherwise importing fails to update UI correctly).
+ value = Objects.requireNonNull(newValue);
+ preferences.saveEnumAsString(key, newValue);
+ }
+
+ @Override
+ public void saveValueFromString(@NonNull String newValue) {
+ setValueFromString(newValue);
+ preferences.saveString(key, newValue);
+ }
+
+ @NonNull
+ @Override
+ public T get() {
+ return value;
+ }
+
+ /**
+ * Availability based on if this setting is currently set to any of the provided types.
+ */
+ @SafeVarargs
+ public final Setting.Availability availability(@NonNull T... types) {
+ return () -> {
+ T currentEnumType = get();
+ for (T enumType : types) {
+ if (currentEnumType == enumType) return true;
+ }
+ return false;
+ };
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/FloatSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/FloatSetting.java
new file mode 100644
index 0000000000..fe6190d651
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/FloatSetting.java
@@ -0,0 +1,83 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class FloatSetting extends Setting {
+
+ public FloatSetting(String key, Float defaultValue) {
+ super(key, defaultValue);
+ }
+
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+
+ public FloatSetting(String key, Float defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+
+ public FloatSetting(String key, Float defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+
+ public FloatSetting(String key, Float defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+
+ public FloatSetting(@NonNull String key, @NonNull Float defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getFloatString(key, defaultValue);
+ }
+
+ @Override
+ protected Float readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return (float) json.getDouble(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Float.valueOf(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void save(@NonNull Float newValue) {
+ // Must set before saving to preferences (otherwise importing fails to update UI correctly).
+ value = Objects.requireNonNull(newValue);
+ preferences.saveFloatString(key, newValue);
+ }
+
+ @Override
+ public void saveValueFromString(@NonNull String newValue) {
+ setValueFromString(newValue);
+ preferences.saveString(key, newValue);
+ }
+
+ @NonNull
+ @Override
+ public Float get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/IntegerSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/IntegerSetting.java
new file mode 100644
index 0000000000..d4d34728fb
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/IntegerSetting.java
@@ -0,0 +1,83 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class IntegerSetting extends Setting {
+
+ public IntegerSetting(String key, Integer defaultValue) {
+ super(key, defaultValue);
+ }
+
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+
+ public IntegerSetting(String key, Integer defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+
+ public IntegerSetting(String key, Integer defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+
+ public IntegerSetting(String key, Integer defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+
+ public IntegerSetting(@NonNull String key, @NonNull Integer defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getIntegerString(key, defaultValue);
+ }
+
+ @Override
+ protected Integer readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return json.getInt(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Integer.valueOf(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void save(@NonNull Integer newValue) {
+ // Must set before saving to preferences (otherwise importing fails to update UI correctly).
+ value = Objects.requireNonNull(newValue);
+ preferences.saveIntegerString(key, newValue);
+ }
+
+ @Override
+ public void saveValueFromString(@NonNull String newValue) {
+ setValueFromString(newValue);
+ preferences.saveString(key, newValue);
+ }
+
+ @NonNull
+ @Override
+ public Integer get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/LongSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/LongSetting.java
new file mode 100644
index 0000000000..91d1b5a937
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/LongSetting.java
@@ -0,0 +1,83 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class LongSetting extends Setting {
+
+ public LongSetting(String key, Long defaultValue) {
+ super(key, defaultValue);
+ }
+
+ public LongSetting(String key, Long defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+
+ public LongSetting(String key, Long defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+
+ public LongSetting(String key, Long defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+
+ public LongSetting(String key, Long defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+
+ public LongSetting(String key, Long defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+
+ public LongSetting(String key, Long defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+
+ public LongSetting(String key, Long defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+
+ public LongSetting(@NonNull String key, @NonNull Long defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getLongString(key, defaultValue);
+ }
+
+ @Override
+ protected Long readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return json.getLong(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Long.valueOf(Objects.requireNonNull(newValue));
+ }
+
+ @Override
+ public void save(@NonNull Long newValue) {
+ // Must set before saving to preferences (otherwise importing fails to update UI correctly).
+ value = Objects.requireNonNull(newValue);
+ preferences.saveLongString(key, newValue);
+ }
+
+ @Override
+ public void saveValueFromString(@NonNull String newValue) {
+ setValueFromString(newValue);
+ preferences.saveString(key, newValue);
+ }
+
+ @NonNull
+ @Override
+ public Long get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/Setting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/Setting.java
new file mode 100644
index 0000000000..cdd81b5b41
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/Setting.java
@@ -0,0 +1,475 @@
+package app.revanced.extension.shared.settings;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.shared.utils.Utils.isSDKAbove;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.jetbrains.annotations.NotNull;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import app.revanced.extension.shared.settings.preference.SharedPrefCategory;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.StringRef;
+import app.revanced.extension.shared.utils.Utils;
+
+/**
+ * @noinspection rawtypes
+ */
+@SuppressWarnings("unused")
+public abstract class Setting {
+
+ /**
+ * Indicates if a {@link Setting} is available to edit and use.
+ * Typically this is dependent upon other BooleanSetting(s) set to 'true',
+ * but this can be used to call into integrations code and check other conditions.
+ */
+ public interface Availability {
+ boolean isAvailable();
+ }
+
+ /**
+ * Availability based on a single parent setting being enabled.
+ */
+ @NonNull
+ public static Availability parent(@NonNull BooleanSetting parent) {
+ return parent::get;
+ }
+
+ /**
+ * Availability based on all parents being enabled.
+ */
+ @NonNull
+ public static Availability parentsAll(@NonNull BooleanSetting... parents) {
+ return () -> {
+ for (BooleanSetting parent : parents) {
+ if (!parent.get()) return false;
+ }
+ return true;
+ };
+ }
+
+ /**
+ * Availability based on any parent being enabled.
+ */
+ @NonNull
+ public static Availability parentsAny(@NonNull BooleanSetting... parents) {
+ return () -> {
+ for (BooleanSetting parent : parents) {
+ if (parent.get()) return true;
+ }
+ return false;
+ };
+ }
+
+ /**
+ * All settings that were instantiated.
+ * When a new setting is created, it is automatically added to this list.
+ */
+ private static final List> SETTINGS = new ArrayList<>();
+
+ /**
+ * Map of setting path to setting object.
+ */
+ private static final Map> PATH_TO_SETTINGS = new HashMap<>();
+
+ /**
+ * Preference all instances are saved to.
+ */
+ public static final SharedPrefCategory preferences = new SharedPrefCategory("revanced");
+
+ @Nullable
+ public static Setting> getSettingFromPath(@NonNull String str) {
+ return PATH_TO_SETTINGS.get(str);
+ }
+
+ /**
+ * @return All settings that have been created.
+ */
+ @NonNull
+ public static List> allLoadedSettings() {
+ return Collections.unmodifiableList(SETTINGS);
+ }
+
+ /**
+ * @return All settings that have been created, sorted by keys.
+ */
+ @NonNull
+ private static List> allLoadedSettingsSorted() {
+ if (isSDKAbove(24)) {
+ SETTINGS.sort(Comparator.comparing((Setting> o) -> o.key));
+ } else {
+ //noinspection ComparatorCombinators
+ Collections.sort(SETTINGS, (o1, o2) -> o1.key.compareTo(o2.key));
+ }
+ return allLoadedSettings();
+ }
+
+ /**
+ * The key used to store the value in the shared preferences.
+ */
+ @NonNull
+ public final String key;
+
+ /**
+ * The default value of the setting.
+ */
+ @NonNull
+ public final T defaultValue;
+
+ /**
+ * If the app should be rebooted, if this setting is changed
+ */
+ public final boolean rebootApp;
+
+ /**
+ * If this setting should be included when importing/exporting settings.
+ */
+ public final boolean includeWithImportExport;
+
+ /**
+ * If this setting is available to edit and use.
+ * Not to be confused with it's status returned from {@link #get()}.
+ */
+ @Nullable
+ private final Availability availability;
+
+ /**
+ * Confirmation message to display, if the user tries to change the setting from the default value.
+ * Currently this works only for Boolean setting types.
+ */
+ @Nullable
+ public final StringRef userDialogMessage;
+
+ // Must be volatile, as some settings are read/write from different threads.
+ // Of note, the object value is persistently stored using SharedPreferences (which is thread safe).
+ /**
+ * The value of the setting.
+ */
+ @NonNull
+ protected volatile T value;
+
+ public Setting(String key, T defaultValue) {
+ this(key, defaultValue, false, true, null, null);
+ }
+
+ public Setting(String key, T defaultValue, boolean rebootApp) {
+ this(key, defaultValue, rebootApp, true, null, null);
+ }
+
+ public Setting(String key, T defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ this(key, defaultValue, rebootApp, includeWithImportExport, null, null);
+ }
+
+ public Setting(String key, T defaultValue, String userDialogMessage) {
+ this(key, defaultValue, false, true, userDialogMessage, null);
+ }
+
+ public Setting(String key, T defaultValue, Availability availability) {
+ this(key, defaultValue, false, true, null, availability);
+ }
+
+ public Setting(String key, T defaultValue, boolean rebootApp, String userDialogMessage) {
+ this(key, defaultValue, rebootApp, true, userDialogMessage, null);
+ }
+
+ public Setting(String key, T defaultValue, boolean rebootApp, Availability availability) {
+ this(key, defaultValue, rebootApp, true, null, availability);
+ }
+
+ public Setting(String key, T defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ this(key, defaultValue, rebootApp, true, userDialogMessage, availability);
+ }
+
+ /**
+ * A setting backed by a shared preference.
+ *
+ * @param key The key used to store the value in the shared preferences.
+ * @param defaultValue The default value of the setting.
+ * @param rebootApp If the app should be rebooted, if this setting is changed.
+ * @param includeWithImportExport If this setting should be shown in the import/export dialog.
+ * @param userDialogMessage Confirmation message to display, if the user tries to change the setting from the default value.
+ * @param availability Condition that must be true, for this setting to be available to configure.
+ */
+ public Setting(@NonNull String key,
+ @NonNull T defaultValue,
+ boolean rebootApp,
+ boolean includeWithImportExport,
+ @Nullable String userDialogMessage,
+ @Nullable Availability availability
+ ) {
+ this.key = Objects.requireNonNull(key);
+ this.value = this.defaultValue = Objects.requireNonNull(defaultValue);
+ this.rebootApp = rebootApp;
+ this.includeWithImportExport = includeWithImportExport;
+ this.userDialogMessage = (userDialogMessage == null) ? null : new StringRef(userDialogMessage);
+ this.availability = availability;
+
+ SETTINGS.add(this);
+ if (PATH_TO_SETTINGS.put(key, this) != null) {
+ // Debug setting may not be created yet so using Logger may cause an initialization crash.
+ // Show a toast instead.
+ Utils.showToastShort(this.getClass().getSimpleName()
+ + " error: Duplicate Setting key found: " + key);
+ }
+
+ load();
+ }
+
+ /**
+ * Migrate a setting value if the path is renamed but otherwise the old and new settings are identical.
+ */
+ public static void migrateOldSettingToNew(@NonNull Setting oldSetting, @NonNull Setting newSetting) {
+ if (!oldSetting.isSetToDefault()) {
+ Logger.printInfo(() -> "Migrating old setting value: " + oldSetting + " into replacement setting: " + newSetting);
+ newSetting.save(oldSetting.value);
+ oldSetting.resetToDefault();
+ }
+ }
+
+ /**
+ * Migrate an old Setting value previously stored in a different SharedPreference.
+ *
+ * This method will be deleted in the future.
+ */
+ public static void migrateFromOldPreferences(@NonNull SharedPrefCategory oldPrefs, @NonNull Setting setting, String settingKey) {
+ if (!oldPrefs.preferences.contains(settingKey)) {
+ return; // Nothing to do.
+ }
+
+ Object newValue = setting.get();
+ final Object migratedValue;
+ if (setting instanceof BooleanSetting) {
+ migratedValue = oldPrefs.getBoolean(settingKey, (Boolean) newValue);
+ } else if (setting instanceof IntegerSetting) {
+ migratedValue = oldPrefs.getIntegerString(settingKey, (Integer) newValue);
+ } else if (setting instanceof LongSetting) {
+ migratedValue = oldPrefs.getLongString(settingKey, (Long) newValue);
+ } else if (setting instanceof FloatSetting) {
+ migratedValue = oldPrefs.getFloatString(settingKey, (Float) newValue);
+ } else if (setting instanceof StringSetting) {
+ migratedValue = oldPrefs.getString(settingKey, (String) newValue);
+ } else {
+ Logger.printException(() -> "Unknown setting: " + setting);
+ // Remove otherwise it'll show a toast on every launch
+ oldPrefs.preferences.edit().remove(settingKey).apply();
+ return;
+ }
+
+ oldPrefs.preferences.edit().remove(settingKey).apply(); // Remove the old setting.
+ if (migratedValue.equals(newValue)) {
+ Logger.printDebug(() -> "Value does not need migrating: " + settingKey);
+ return; // Old value is already equal to the new setting value.
+ }
+
+ Logger.printDebug(() -> "Migrating old preference value into current preference: " + settingKey);
+ //noinspection unchecked
+ setting.save(migratedValue);
+ }
+
+ /**
+ * Sets, but does _not_ persistently save the value.
+ * This method is only to be used by the Settings preference code.
+ *
+ * This intentionally is a static method to deter
+ * accidental usage when {@link #save(Object)} was intended.
+ */
+ public static void privateSetValueFromString(@NonNull Setting> setting, @NonNull String newValue) {
+ setting.setValueFromString(newValue);
+ }
+
+ /**
+ * Sets the value of {@link #value}, but do not save to {@link #preferences}.
+ */
+ protected abstract void setValueFromString(@NonNull String newValue);
+
+ /**
+ * Load and set the value of {@link #value}.
+ */
+ protected abstract void load();
+
+ /**
+ * Persistently saves the value.
+ */
+ public abstract void save(@NonNull T newValue);
+
+ /**
+ * Persistently saves the value using strings.
+ */
+ public abstract void saveValueFromString(@NonNull String newValue);
+
+ @NonNull
+ public abstract T get();
+
+ /**
+ * Identical to calling {@link #save(Object)} using {@link #defaultValue}.
+ */
+ public void resetToDefault() {
+ save(defaultValue);
+ }
+
+ /**
+ * @return if this setting can be configured and used.
+ */
+ public boolean isAvailable() {
+ return availability == null || availability.isAvailable();
+ }
+
+ /**
+ * @return if the currently set value is the same as {@link #defaultValue}
+ * @noinspection BooleanMethodIsAlwaysInverted
+ */
+ public boolean isSetToDefault() {
+ return value.equals(defaultValue);
+ }
+
+ @NotNull
+ @Override
+ public String toString() {
+ return key + "=" + get();
+ }
+
+ // region Import / export
+
+ /**
+ * If a setting path has this prefix, then remove it before importing/exporting.
+ */
+ private static final String OPTIONAL_REVANCED_SETTINGS_PREFIX = "revanced_";
+
+ /**
+ * The path, minus any 'revanced' prefix to keep json concise.
+ */
+ private String getImportExportKey() {
+ if (key.startsWith(OPTIONAL_REVANCED_SETTINGS_PREFIX)) {
+ return key.substring(OPTIONAL_REVANCED_SETTINGS_PREFIX.length());
+ }
+ return key;
+ }
+
+ /**
+ * @param importExportKey The JSON key. The JSONObject parameter will contain data for this key.
+ * @return the value stored using the import/export key. Do not set any values in this method.
+ */
+ protected abstract T readFromJSON(JSONObject json, String importExportKey) throws JSONException;
+
+ /**
+ * Saves this instance to JSON.
+ *
+ * To keep the JSON simple and readable,
+ * subclasses should not write out any embedded types (such as JSON Array or Dictionaries).
+ *
+ * If this instance is not a type supported natively by JSON (ie: it's not a String/Integer/Float/Long),
+ * then subclasses can override this method and write out a String value representing the value.
+ */
+ protected void writeToJSON(JSONObject json, String importExportKey) throws JSONException {
+ json.put(importExportKey, value);
+ }
+
+ @NonNull
+ public static String exportToJson(@Nullable Context alertDialogContext) {
+ try {
+ JSONObject json = new JSONObject();
+ for (Setting> setting : allLoadedSettingsSorted()) {
+ String importExportKey = setting.getImportExportKey();
+ if (json.has(importExportKey)) {
+ throw new IllegalArgumentException("duplicate key found: " + importExportKey);
+ }
+
+ final boolean exportDefaultValues = false; // Enable to see what all settings looks like in the UI.
+ //noinspection ConstantValue
+ if (setting.includeWithImportExport && (!setting.isSetToDefault() || exportDefaultValues)) {
+ setting.writeToJSON(json, importExportKey);
+ }
+ }
+ if (alertDialogContext != null) {
+ app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings.showExportWarningIfNeeded(alertDialogContext);
+ }
+
+ if (json.length() == 0) {
+ return "";
+ }
+
+ String export = json.toString(0);
+
+ // Remove the outer JSON braces to make the output more compact,
+ // and leave less chance of the user forgetting to copy it
+ return export.substring(2, export.length() - 2);
+ } catch (JSONException e) {
+ Logger.printException(() -> "Export failure", e); // should never happen
+ return "";
+ }
+ }
+
+ public static boolean importFromJSON(@NonNull String settingsJsonString) {
+ return importFromJSON(settingsJsonString, true);
+ }
+
+ /**
+ * @return if any settings that require a reboot were changed.
+ */
+ public static boolean importFromJSON(@NonNull String settingsJsonString, boolean isYouTube) {
+ try {
+ if (!settingsJsonString.matches("[\\s\\S]*\\{")) {
+ settingsJsonString = '{' + settingsJsonString + '}'; // Restore outer JSON braces
+ }
+ JSONObject json = new JSONObject(settingsJsonString);
+
+ boolean rebootSettingChanged = false;
+ int numberOfSettingsImported = 0;
+ for (Setting setting : SETTINGS) {
+ String key = setting.getImportExportKey();
+ if (json.has(key)) {
+ Object value = setting.readFromJSON(json, key);
+ if (!setting.get().equals(value)) {
+ rebootSettingChanged |= setting.rebootApp;
+ //noinspection unchecked
+ setting.save(value);
+ }
+ numberOfSettingsImported++;
+ } else if (setting.includeWithImportExport && !setting.isSetToDefault()) {
+ Logger.printDebug(() -> "Resetting to default: " + setting);
+ rebootSettingChanged |= setting.rebootApp;
+ setting.resetToDefault();
+ }
+ }
+
+ // SB Enum categories are saved using StringSettings.
+ // Which means they need to reload again if changed by other code (such as here).
+ // This call could be removed by creating a custom Setting class that manages the
+ // "String <-> Enum" logic or by adding an event hook of when settings are imported.
+ // But for now this is simple and works.
+ if (isYouTube) {
+ app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings.updateFromImportedSettings();
+ } else {
+ app.revanced.extension.music.sponsorblock.SponsorBlockSettings.updateFromImportedSettings();
+ }
+
+ Utils.showToastLong(numberOfSettingsImported == 0
+ ? str("revanced_extended_settings_import_reset")
+ : str("revanced_extended_settings_import_success", numberOfSettingsImported));
+
+ return rebootSettingChanged;
+ } catch (JSONException | IllegalArgumentException ex) {
+ Utils.showToastLong(str("revanced_extended_settings_import_failed", ex.getMessage()));
+ Logger.printInfo(() -> "", ex);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Import failure: " + ex.getMessage(), ex); // should never happen
+ }
+ return false;
+ }
+
+ // End import / export
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/StringSetting.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/StringSetting.java
new file mode 100644
index 0000000000..fda7e516cc
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/StringSetting.java
@@ -0,0 +1,83 @@
+package app.revanced.extension.shared.settings;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Objects;
+
+@SuppressWarnings("unused")
+public class StringSetting extends Setting {
+
+ public StringSetting(String key, String defaultValue) {
+ super(key, defaultValue);
+ }
+
+ public StringSetting(String key, String defaultValue, boolean rebootApp) {
+ super(key, defaultValue, rebootApp);
+ }
+
+ public StringSetting(String key, String defaultValue, boolean rebootApp, boolean includeWithImportExport) {
+ super(key, defaultValue, rebootApp, includeWithImportExport);
+ }
+
+ public StringSetting(String key, String defaultValue, String userDialogMessage) {
+ super(key, defaultValue, userDialogMessage);
+ }
+
+ public StringSetting(String key, String defaultValue, Availability availability) {
+ super(key, defaultValue, availability);
+ }
+
+ public StringSetting(String key, String defaultValue, boolean rebootApp, String userDialogMessage) {
+ super(key, defaultValue, rebootApp, userDialogMessage);
+ }
+
+ public StringSetting(String key, String defaultValue, boolean rebootApp, Availability availability) {
+ super(key, defaultValue, rebootApp, availability);
+ }
+
+ public StringSetting(String key, String defaultValue, boolean rebootApp, String userDialogMessage, Availability availability) {
+ super(key, defaultValue, rebootApp, userDialogMessage, availability);
+ }
+
+ public StringSetting(@NonNull String key, @NonNull String defaultValue, boolean rebootApp, boolean includeWithImportExport, @Nullable String userDialogMessage, @Nullable Availability availability) {
+ super(key, defaultValue, rebootApp, includeWithImportExport, userDialogMessage, availability);
+ }
+
+ @Override
+ protected void load() {
+ value = preferences.getString(key, defaultValue);
+ }
+
+ @Override
+ protected String readFromJSON(JSONObject json, String importExportKey) throws JSONException {
+ return json.getString(importExportKey);
+ }
+
+ @Override
+ protected void setValueFromString(@NonNull String newValue) {
+ value = Objects.requireNonNull(newValue);
+ }
+
+ @Override
+ public void save(@NonNull String newValue) {
+ // Must set before saving to preferences (otherwise importing fails to update UI correctly).
+ value = Objects.requireNonNull(newValue);
+ preferences.saveString(key, newValue);
+ }
+
+ @Override
+ public void saveValueFromString(@NonNull String newValue) {
+ setValueFromString(newValue);
+ preferences.saveString(key, newValue);
+ }
+
+ @NonNull
+ @Override
+ public String get() {
+ return value;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java
new file mode 100644
index 0000000000..b2bac3d67c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/AbstractPreferenceFragment.java
@@ -0,0 +1,287 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.utils.ResourceUtils.getXmlIdentifier;
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.annotation.SuppressLint;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.preference.EditTextPreference;
+import android.preference.ListPreference;
+import android.preference.Preference;
+import android.preference.PreferenceFragment;
+import android.preference.PreferenceManager;
+import android.preference.PreferenceScreen;
+import android.preference.SwitchPreference;
+import android.view.View;
+import android.widget.ListView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+@SuppressWarnings({"unused", "deprecation"})
+public abstract class AbstractPreferenceFragment extends PreferenceFragment {
+ /**
+ * Indicates that if a preference changes,
+ * to apply the change from the Setting to the UI component.
+ */
+ public static boolean settingImportInProgress;
+
+ /**
+ * Confirm and restart dialog button text and title.
+ * Set by subclasses if Strings cannot be added as a resource.
+ */
+ @Nullable
+ protected static String restartDialogMessage;
+
+ /**
+ * Used to prevent showing reboot dialog, if user cancels a setting user dialog.
+ */
+ private boolean showingUserDialogMessage;
+
+ private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
+ try {
+ if (str == null) {
+ return;
+ }
+ Setting> setting = Setting.getSettingFromPath(str);
+ if (setting == null) {
+ return;
+ }
+ Preference pref = findPreference(str);
+ if (pref == null) {
+ return;
+ }
+
+ // Apply 'Setting <- Preference', unless during importing when it needs to be 'Setting -> Preference'.
+ updatePreference(pref, setting, true, settingImportInProgress);
+ // Update any other preference availability that may now be different.
+ updateUIAvailability();
+
+ if (settingImportInProgress) {
+ return;
+ }
+
+ if (!showingUserDialogMessage) {
+ if (setting.userDialogMessage != null && ((SwitchPreference) pref).isChecked() != (Boolean) setting.defaultValue) {
+ showSettingUserDialogConfirmation((SwitchPreference) pref, (BooleanSetting) setting);
+ } else if (setting.rebootApp) {
+ showRestartDialog(getActivity());
+ }
+ }
+
+ } catch (Exception ex) {
+ Logger.printException(() -> "OnSharedPreferenceChangeListener failure", ex);
+ }
+ };
+
+ /**
+ * Initialize this instance, and do any custom behavior.
+ *
+ * To ensure all {@link Setting} instances are correctly synced to the UI,
+ * it is important that subclasses make a call or otherwise reference their Settings class bundle
+ * so all app specific {@link Setting} instances are loaded before this method returns.
+ */
+ protected void initialize() {
+ final int id = getXmlIdentifier("revanced_prefs");
+
+ if (id == 0) return;
+ addPreferencesFromResource(id);
+ Utils.sortPreferenceGroups(getPreferenceScreen());
+ }
+
+ private void showSettingUserDialogConfirmation(SwitchPreference switchPref, BooleanSetting setting) {
+ Utils.verifyOnMainThread();
+
+ final var context = getActivity();
+ showingUserDialogMessage = true;
+ assert setting.userDialogMessage != null;
+ new AlertDialog.Builder(context)
+ .setTitle(android.R.string.dialog_alert_title)
+ .setMessage(setting.userDialogMessage.toString())
+ .setPositiveButton(android.R.string.ok, (dialog, id) -> {
+ if (setting.rebootApp) {
+ showRestartDialog(context);
+ }
+ })
+ .setNegativeButton(android.R.string.cancel, (dialog, id) -> {
+ switchPref.setChecked(setting.defaultValue); // Recursive call that resets the Setting value.
+ })
+ .setOnDismissListener(dialog -> showingUserDialogMessage = false)
+ .setCancelable(false)
+ .show();
+ }
+
+ /**
+ * Updates all Preferences values and their availability using the current values in {@link Setting}.
+ */
+ protected void updateUIToSettingValues() {
+ updatePreferenceScreen(getPreferenceScreen(), true, true);
+ }
+
+ /**
+ * Updates Preferences availability only using the status of {@link Setting}.
+ */
+ protected void updateUIAvailability() {
+ updatePreferenceScreen(getPreferenceScreen(), false, false);
+ }
+
+ /**
+ * Syncs all UI Preferences to any {@link Setting} they represent.
+ */
+ private void updatePreferenceScreen(@NonNull PreferenceScreen screen,
+ boolean syncSettingValue,
+ boolean applySettingToPreference) {
+ // Alternatively this could iterate thru all Settings and check for any matching Preferences,
+ // but there are many more Settings than UI preferences so it's more efficient to only check
+ // the Preferences.
+ for (int i = 0, prefCount = screen.getPreferenceCount(); i < prefCount; i++) {
+ Preference pref = screen.getPreference(i);
+ if (pref instanceof PreferenceScreen preferenceScreen) {
+ updatePreferenceScreen(preferenceScreen, syncSettingValue, applySettingToPreference);
+ } else if (pref.hasKey()) {
+ String key = pref.getKey();
+ Setting> setting = Setting.getSettingFromPath(key);
+ if (setting != null) {
+ updatePreference(pref, setting, syncSettingValue, applySettingToPreference);
+ }
+ }
+ }
+ }
+
+ /**
+ * Handles syncing a UI Preference with the {@link Setting} that backs it.
+ * If needed, subclasses can override this to handle additional UI Preference types.
+ *
+ * @param applySettingToPreference If true, then apply {@link Setting} -> Preference.
+ * If false, then apply {@link Setting} <- Preference.
+ */
+ protected void syncSettingWithPreference(@NonNull Preference pref,
+ @NonNull Setting> setting,
+ boolean applySettingToPreference) {
+ if (pref instanceof SwitchPreference switchPreference) {
+ BooleanSetting boolSetting = (BooleanSetting) setting;
+ if (applySettingToPreference) {
+ switchPreference.setChecked(boolSetting.get());
+ } else {
+ BooleanSetting.privateSetValue(boolSetting, switchPreference.isChecked());
+ }
+ } else if (pref instanceof EditTextPreference editTextPreference) {
+ if (applySettingToPreference) {
+ editTextPreference.setText(setting.get().toString());
+ } else {
+ Setting.privateSetValueFromString(setting, editTextPreference.getText());
+ }
+ } else if (pref instanceof ListPreference listPreference) {
+ if (applySettingToPreference) {
+ listPreference.setValue(setting.get().toString());
+ } else {
+ Setting.privateSetValueFromString(setting, listPreference.getValue());
+ }
+ updateListPreferenceSummary(listPreference, setting);
+ } else {
+ Logger.printException(() -> "Setting cannot be handled: " + pref.getClass() + ": " + pref);
+ }
+ }
+
+ /**
+ * Updates a UI Preference with the {@link Setting} that backs it.
+ *
+ * @param syncSetting If the UI should be synced {@link Setting} <-> Preference
+ * @param applySettingToPreference If true, then apply {@link Setting} -> Preference.
+ * If false, then apply {@link Setting} <- Preference.
+ */
+ private void updatePreference(@NonNull Preference pref, @NonNull Setting> setting,
+ boolean syncSetting, boolean applySettingToPreference) {
+ if (!syncSetting && applySettingToPreference) {
+ throw new IllegalArgumentException();
+ }
+
+ if (syncSetting) {
+ syncSettingWithPreference(pref, setting, applySettingToPreference);
+ }
+
+ updatePreferenceAvailability(pref, setting);
+ }
+
+ protected void updatePreferenceAvailability(@NonNull Preference pref, @NonNull Setting> setting) {
+ pref.setEnabled(setting.isAvailable());
+ }
+
+ public static void updateListPreferenceSummary(ListPreference listPreference, Setting> setting) {
+ String objectStringValue = setting.get().toString();
+ int entryIndex = listPreference.findIndexOfValue(objectStringValue);
+ if (entryIndex >= 0) {
+ listPreference.setValue(objectStringValue);
+ objectStringValue = listPreference.getEntries()[entryIndex].toString();
+ }
+ listPreference.setSummary(objectStringValue);
+ }
+
+ public static void showRestartDialog(@NonNull final Context context) {
+ if (restartDialogMessage == null) {
+ restartDialogMessage = str("revanced_extended_restart_message");
+ }
+ showRestartDialog(context, restartDialogMessage);
+ }
+
+ public static void showRestartDialog(@NonNull final Context context, String message) {
+ showRestartDialog(context, message, 0);
+ }
+
+ public static void showRestartDialog(@NonNull final Context context, String message, long delay) {
+ Utils.verifyOnMainThread();
+
+ new AlertDialog.Builder(context)
+ .setMessage(message)
+ .setPositiveButton(android.R.string.ok, (dialog, id)
+ -> Utils.runOnMainThreadDelayed(() -> Utils.restartApp(context), delay))
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+
+ @SuppressLint("ResourceType")
+ @Override
+ public void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ try {
+ PreferenceManager preferenceManager = getPreferenceManager();
+ preferenceManager.setSharedPreferencesName(Setting.preferences.name);
+
+ // Must initialize before adding change listener,
+ // otherwise the syncing of Setting -> UI
+ // causes a callback to the listener even though nothing changed.
+ initialize();
+ updateUIToSettingValues();
+
+ preferenceManager.getSharedPreferences().registerOnSharedPreferenceChangeListener(listener);
+ } catch (Exception ex) {
+ Logger.printException(() -> "onCreate() failure", ex);
+ }
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+
+ final View rootView = getView();
+ if (rootView == null) return;
+ ListView listView = getView().findViewById(android.R.id.list);
+ if (listView == null) return;
+ listView.setDivider(null);
+ listView.setDividerHeight(0);
+ }
+
+ @Override
+ public void onDestroy() {
+ getPreferenceManager().getSharedPreferences().unregisterOnSharedPreferenceChangeListener(listener);
+ super.onDestroy();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/HtmlPreference.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/HtmlPreference.java
new file mode 100644
index 0000000000..3023ee2aa8
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/HtmlPreference.java
@@ -0,0 +1,34 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static android.text.Html.FROM_HTML_MODE_COMPACT;
+
+import android.content.Context;
+import android.preference.Preference;
+import android.text.Html;
+import android.util.AttributeSet;
+
+/**
+ * Allows using basic html for the summary text.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class HtmlPreference extends Preference {
+ {
+ setSummary(Html.fromHtml(getSummary().toString(), FROM_HTML_MODE_COMPACT));
+ }
+
+ public HtmlPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public HtmlPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public HtmlPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public HtmlPreference(Context context) {
+ super(context);
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java
new file mode 100644
index 0000000000..414ca0a185
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ImportExportPreference.java
@@ -0,0 +1,104 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.annotation.TargetApi;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.preference.EditTextPreference;
+import android.preference.Preference;
+import android.text.InputType;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.widget.EditText;
+
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener {
+
+ private String existingSettings;
+
+ @TargetApi(26)
+ private void init() {
+ setSelectable(true);
+
+ EditText editText = getEditText();
+ editText.setTextIsSelectable(true);
+ editText.setAutofillHints((String) null);
+ editText.setInputType(editText.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ editText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 7); // Use a smaller font to reduce text wrap.
+
+ setOnPreferenceClickListener(this);
+ }
+
+ public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+
+ public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ public ImportExportPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public ImportExportPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ try {
+ // Must set text before preparing dialog, otherwise text is non selectable if this preference is later reopened.
+ existingSettings = Setting.exportToJson(getContext());
+ getEditText().setText(existingSettings);
+ } catch (Exception ex) {
+ Logger.printException(() -> "showDialog failure", ex);
+ }
+ return true;
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ try {
+ Utils.setEditTextDialogTheme(builder, true);
+ super.onPrepareDialogBuilder(builder);
+ // Show the user the settings in JSON format.
+ builder.setNeutralButton(
+ str("revanced_extended_settings_import_copy"), (dialog, which) ->
+ Utils.setClipboard(getEditText().getText().toString())
+ ).setPositiveButton(
+ str("revanced_extended_settings_import"), (dialog, which) ->
+ importSettings(getEditText().getText().toString())
+ );
+ } catch (Exception ex) {
+ Logger.printException(() -> "onPrepareDialogBuilder failure", ex);
+ }
+ }
+
+ private void importSettings(String replacementSettings) {
+ try {
+ if (replacementSettings.equals(existingSettings)) {
+ return;
+ }
+ AbstractPreferenceFragment.settingImportInProgress = true;
+ final boolean rebootNeeded = Setting.importFromJSON(replacementSettings);
+ if (rebootNeeded) {
+ AbstractPreferenceFragment.showRestartDialog(getContext());
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "importSettings failure", ex);
+ } finally {
+ AbstractPreferenceFragment.settingImportInProgress = false;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java
new file mode 100644
index 0000000000..43305c23c3
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/ResettableEditTextPreference.java
@@ -0,0 +1,78 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.preference.EditTextPreference;
+import android.util.AttributeSet;
+import android.widget.Button;
+import android.widget.EditText;
+
+import java.util.Objects;
+
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class ResettableEditTextPreference extends EditTextPreference {
+
+ public ResettableEditTextPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public ResettableEditTextPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public ResettableEditTextPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public ResettableEditTextPreference(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ Utils.setEditTextDialogTheme(builder);
+ super.onPrepareDialogBuilder(builder);
+
+ final CharSequence title = getTitle();
+ if (title != null) {
+ builder.setTitle(getTitle());
+ }
+ final Setting> setting = Setting.getSettingFromPath(getKey());
+ if (setting != null) {
+ builder.setNeutralButton(str("revanced_extended_settings_reset"), null);
+ }
+ }
+
+ @Override
+ protected void showDialog(Bundle state) {
+ super.showDialog(state);
+
+ if (!(getDialog() instanceof AlertDialog alertDialog)) {
+ return;
+ }
+
+ // Override the button click listener to prevent dismissing the dialog.
+ Button button = alertDialog.getButton(AlertDialog.BUTTON_NEUTRAL);
+ if (button == null) {
+ return;
+ }
+ button.setOnClickListener(v -> {
+ try {
+ Setting> setting = Objects.requireNonNull(Setting.getSettingFromPath(getKey()));
+ String defaultStringValue = setting.defaultValue.toString();
+ EditText editText = getEditText();
+ editText.setText(defaultStringValue);
+ editText.setSelection(defaultStringValue.length()); // move cursor to end of text
+ } catch (Exception ex) {
+ Logger.printException(() -> "reset failure", ex);
+ }
+ });
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java
new file mode 100644
index 0000000000..5122ba191d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/SharedPrefCategory.java
@@ -0,0 +1,193 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.PreferenceFragment;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Objects;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+/**
+ * Shared categories, and helper methods.
+ *
+ * The various save methods store numbers as Strings,
+ * which is required if using {@link PreferenceFragment}.
+ *
+ * If saved numbers will not be used with a preference fragment,
+ * then store the primitive numbers using the {@link #preferences} itself.
+ */
+public class SharedPrefCategory {
+ @NonNull
+ public final String name;
+ @NonNull
+ public final SharedPreferences preferences;
+
+ public SharedPrefCategory(@NonNull String name) {
+ this.name = Objects.requireNonNull(name);
+ preferences = Objects.requireNonNull(Utils.getContext()).getSharedPreferences(name, Context.MODE_PRIVATE);
+ }
+
+ private void removeConflictingPreferenceKeyValue(@NonNull String key) {
+ Logger.printException(() -> "Found conflicting preference: " + key);
+ removeKey(key);
+ }
+
+ private void saveObjectAsString(@NonNull String key, @Nullable Object value) {
+ preferences.edit().putString(key, (value == null ? null : value.toString())).apply();
+ }
+
+ /**
+ * Removes any preference data type that has the specified key.
+ */
+ public void removeKey(@NonNull String key) {
+ preferences.edit().remove(Objects.requireNonNull(key)).apply();
+ }
+
+ public void saveBoolean(@NonNull String key, boolean value) {
+ preferences.edit().putBoolean(key, value).apply();
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveEnumAsString(@NonNull String key, @Nullable Enum> value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveIntegerString(@NonNull String key, @Nullable Integer value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveLongString(@NonNull String key, @Nullable Long value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveFloatString(@NonNull String key, @Nullable Float value) {
+ saveObjectAsString(key, value);
+ }
+
+ /**
+ * @param value a NULL parameter removes the value from the preferences
+ */
+ public void saveString(@NonNull String key, @Nullable String value) {
+ saveObjectAsString(key, value);
+ }
+
+ @NonNull
+ public String getString(@NonNull String key, @NonNull String _default) {
+ Objects.requireNonNull(_default);
+ try {
+ return preferences.getString(key, _default);
+ } catch (ClassCastException ex) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ return _default;
+ }
+ }
+
+ @NonNull
+ public > T getEnum(@NonNull String key, @NonNull T _default) {
+ Objects.requireNonNull(_default);
+ try {
+ String enumName = preferences.getString(key, null);
+ if (enumName != null) {
+ try {
+ // noinspection unchecked
+ return (T) Enum.valueOf(_default.getClass(), enumName);
+ } catch (IllegalArgumentException ex) {
+ // Info level to allow removing enum values in the future without showing any user errors.
+ Logger.printInfo(() -> "Using default, and ignoring unknown enum value: " + enumName);
+ removeKey(key);
+ }
+ }
+ } catch (ClassCastException ex) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ }
+ return _default;
+ }
+
+ public boolean getBoolean(@NonNull String key, boolean _default) {
+ try {
+ return preferences.getBoolean(key, _default);
+ } catch (ClassCastException ex) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ return _default;
+ }
+ }
+
+ @NonNull
+ public Integer getIntegerString(@NonNull String key, @NonNull Integer _default) {
+ try {
+ String value = preferences.getString(key, null);
+ if (value != null) {
+ return Integer.valueOf(value);
+ }
+ return _default;
+ } catch (ClassCastException | NumberFormatException ex) {
+ try {
+ // Old data previously stored as primitive.
+ return preferences.getInt(key, _default);
+ } catch (ClassCastException ex2) {
+ // Value stored is a completely different type (should never happen).
+ removeConflictingPreferenceKeyValue(key);
+ }
+ }
+ return _default;
+ }
+
+ @NonNull
+ public Long getLongString(@NonNull String key, @NonNull Long _default) {
+ try {
+ String value = preferences.getString(key, null);
+ if (value != null) {
+ return Long.valueOf(value);
+ }
+ } catch (ClassCastException | NumberFormatException ex) {
+ try {
+ return preferences.getLong(key, _default);
+ } catch (ClassCastException ex2) {
+ removeConflictingPreferenceKeyValue(key);
+ }
+ }
+ return _default;
+ }
+
+ @NonNull
+ public Float getFloatString(@NonNull String key, @NonNull Float _default) {
+ try {
+ String value = preferences.getString(key, null);
+ if (value != null) {
+ return Float.valueOf(value);
+ }
+ } catch (ClassCastException | NumberFormatException ex) {
+ try {
+ return preferences.getFloat(key, _default);
+ } catch (ClassCastException ex2) {
+ removeConflictingPreferenceKeyValue(key);
+ }
+ }
+ return _default;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return name;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/WebViewDialog.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/WebViewDialog.java
new file mode 100644
index 0000000000..d971540eef
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/WebViewDialog.java
@@ -0,0 +1,62 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Window;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+/**
+ * Displays html content as a dialog. Any links a user taps on are opened in an external browser.
+ */
+@SuppressWarnings("deprecation")
+public class WebViewDialog extends Dialog {
+
+ private final String htmlContent;
+
+ public WebViewDialog(@NonNull Context context, @NonNull String htmlContent) {
+ super(context);
+ this.htmlContent = htmlContent;
+ }
+
+ // JS required to hide any broken images. No remote javascript is ever loaded.
+ @SuppressLint("SetJavaScriptEnabled")
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+
+ WebView webView = new WebView(getContext());
+ webView.getSettings().setJavaScriptEnabled(true);
+ webView.setWebViewClient(new OpenLinksExternallyWebClient());
+ webView.loadDataWithBaseURL(null, htmlContent, "text/html", "utf-8", null);
+
+ setContentView(webView);
+ }
+
+ private class OpenLinksExternallyWebClient extends WebViewClient {
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, String url) {
+ try {
+ Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+ getContext().startActivity(intent);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Open link failure", ex);
+ }
+ // Dismiss the about dialog using a delay,
+ // otherwise without a delay the UI looks hectic with the dialog dismissing
+ // to show the settings while simultaneously a web browser is opening.
+ Utils.runOnMainThreadDelayed(WebViewDialog.this::dismiss, 500);
+ return true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/WideListPreference.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/WideListPreference.java
new file mode 100644
index 0000000000..08bee7bf66
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/WideListPreference.java
@@ -0,0 +1,34 @@
+package app.revanced.extension.shared.settings.preference;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.preference.ListPreference;
+import android.util.AttributeSet;
+
+import app.revanced.extension.shared.utils.Utils;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class WideListPreference extends ListPreference {
+
+ public WideListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public WideListPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public WideListPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public WideListPreference(Context context) {
+ super(context);
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ Utils.setEditTextDialogTheme(builder, true);
+ super.onPrepareDialogBuilder(builder);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/YouTubeDataAPIDialogBuilder.java b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/YouTubeDataAPIDialogBuilder.java
new file mode 100644
index 0000000000..872183e971
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/settings/preference/YouTubeDataAPIDialogBuilder.java
@@ -0,0 +1,61 @@
+package app.revanced.extension.shared.settings.preference;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.app.Activity;
+import android.graphics.Point;
+import android.view.Display;
+import android.view.Window;
+import android.view.WindowManager;
+
+import app.revanced.extension.shared.utils.BaseThemeUtils;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+/**
+ * Used by YouTube and YouTube Music.
+ */
+public class YouTubeDataAPIDialogBuilder {
+ private static final String URL_CREATE_PROJECT = "https://console.cloud.google.com/projectcreate";
+ private static final String URL_MARKET_PLACE = "https://console.cloud.google.com/marketplace/product/google/youtube.googleapis.com";
+
+ public static void showDialog(Activity mActivity) {
+ try {
+ final String backgroundColorHex = BaseThemeUtils.getBackgroundColorHexString();
+ final String foregroundColorHex = BaseThemeUtils.getForegroundColorHexString();
+
+ final String htmlDialog = "" +
+ "" +
+ String.format(
+ "",
+ backgroundColorHex, foregroundColorHex, foregroundColorHex) +
+ "
" +
+ str("revanced_return_youtube_username_youtube_data_api_v3_dialog_title") +
+ " " +
+ String.format(
+ str("revanced_return_youtube_username_youtube_data_api_v3_dialog_message"),
+ URL_CREATE_PROJECT,
+ URL_MARKET_PLACE
+ ) +
+ "
";
+
+ Utils.runOnMainThreadNowOrLater(() -> {
+ WebViewDialog webViewDialog = new WebViewDialog(mActivity, htmlDialog);
+ webViewDialog.show();
+
+ final Window window = webViewDialog.getWindow();
+ if (window == null) return;
+ Display display = mActivity.getWindowManager().getDefaultDisplay();
+ Point size = new Point();
+ display.getSize(size);
+
+ WindowManager.LayoutParams params = window.getAttributes();
+ params.height = (int) (size.y * 0.6);
+
+ window.setAttributes(params);
+ });
+ } catch (Exception ex) {
+ Logger.printException(() -> "dialogBuilder failure", ex);
+ }
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/sponsorblock/requests/SBRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/shared/sponsorblock/requests/SBRoutes.java
new file mode 100644
index 0000000000..5e972d5853
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/sponsorblock/requests/SBRoutes.java
@@ -0,0 +1,20 @@
+package app.revanced.extension.shared.sponsorblock.requests;
+
+import static app.revanced.extension.shared.requests.Route.Method.GET;
+import static app.revanced.extension.shared.requests.Route.Method.POST;
+
+import app.revanced.extension.shared.requests.Route;
+
+public class SBRoutes {
+ public static final Route IS_USER_VIP = new Route(GET, "/api/isUserVIP?userID={user_id}");
+ public static final Route GET_SEGMENTS = new Route(GET, "/api/skipSegments?videoID={video_id}&categories={categories}");
+ public static final Route VIEWED_SEGMENT = new Route(POST, "/api/viewedVideoSponsorTime?UUID={segment_id}");
+ public static final Route GET_USER_STATS = new Route(GET, "/api/userInfo?userID={user_id}&values=[\"userID\",\"userName\",\"reputation\",\"segmentCount\",\"ignoredSegmentCount\",\"viewCount\",\"minutesSaved\"]");
+ public static final Route CHANGE_USERNAME = new Route(POST, "/api/setUsername?userID={user_id}&username={username}");
+ public static final Route SUBMIT_SEGMENTS = new Route(POST, "/api/skipSegments?userID={user_id}&videoID={video_id}&category={category}&startTime={start_time}&endTime={end_time}&videoDuration={duration}");
+ public static final Route VOTE_ON_SEGMENT_QUALITY = new Route(POST, "/api/voteOnSponsorTime?userID={user_id}&UUID={segment_id}&type={type}");
+ public static final Route VOTE_ON_SEGMENT_CATEGORY = new Route(POST, "/api/voteOnSponsorTime?userID={user_id}&UUID={segment_id}&category={category}");
+
+ public SBRoutes() {
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/BaseThemeUtils.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/BaseThemeUtils.java
new file mode 100644
index 0000000000..ffc80d19a1
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/BaseThemeUtils.java
@@ -0,0 +1,73 @@
+package app.revanced.extension.shared.utils;
+
+import static app.revanced.extension.shared.utils.ResourceUtils.getColor;
+import static app.revanced.extension.shared.utils.ResourceUtils.getColorIdentifier;
+
+import android.graphics.Color;
+
+@SuppressWarnings("unused")
+public class BaseThemeUtils {
+ private static int themeValue = 1;
+
+ /**
+ * Injection point.
+ */
+ public static void setTheme(Enum> value) {
+ final int newOrdinalValue = value.ordinal();
+ if (themeValue != newOrdinalValue) {
+ themeValue = newOrdinalValue;
+ Logger.printDebug(() -> "Theme value: " + newOrdinalValue);
+ }
+ }
+
+ public static boolean isDarkTheme() {
+ return themeValue == 1;
+ }
+
+ public static String getColorHexString(int color) {
+ return String.format("#%06X", (0xFFFFFF & color));
+ }
+
+ /**
+ * Subclasses can override this and provide a themed color.
+ */
+ public static int getLightColor() {
+ return Color.WHITE;
+ }
+
+ /**
+ * Subclasses can override this and provide a themed color.
+ */
+ public static int getDarkColor() {
+ return Color.BLACK;
+ }
+
+ public static String getBackgroundColorHexString() {
+ return getColorHexString(getBackgroundColor());
+ }
+
+ public static String getForegroundColorHexString() {
+ return getColorHexString(getForegroundColor());
+ }
+
+ public static int getBackgroundColor() {
+ final String colorName = isDarkTheme() ? "yt_black1" : "yt_white1";
+ final int colorIdentifier = getColorIdentifier(colorName);
+ if (colorIdentifier != 0) {
+ return getColor(colorName);
+ } else {
+ return isDarkTheme() ? getDarkColor() : getLightColor();
+ }
+ }
+
+ public static int getForegroundColor() {
+ final String colorName = isDarkTheme() ? "yt_white1" : "yt_black1";
+ final int colorIdentifier = getColorIdentifier(colorName);
+ if (colorIdentifier != 0) {
+ return getColor(colorName);
+ } else {
+ return isDarkTheme() ? getLightColor() : getDarkColor();
+ }
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/ByteTrieSearch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/ByteTrieSearch.java
new file mode 100644
index 0000000000..1708f567ce
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/ByteTrieSearch.java
@@ -0,0 +1,50 @@
+package app.revanced.extension.shared.utils;
+
+import androidx.annotation.NonNull;
+
+import java.nio.charset.StandardCharsets;
+
+public final class ByteTrieSearch extends TrieSearch {
+
+ private static final class ByteTrieNode extends TrieNode {
+ ByteTrieNode() {
+ super();
+ }
+
+ ByteTrieNode(char nodeCharacterValue) {
+ super(nodeCharacterValue);
+ }
+
+ @Override
+ TrieNode createNode(char nodeCharacterValue) {
+ return new ByteTrieNode(nodeCharacterValue);
+ }
+
+ @Override
+ char getCharValue(byte[] text, int index) {
+ return (char) text[index];
+ }
+
+ @Override
+ int getTextLength(byte[] text) {
+ return text.length;
+ }
+ }
+
+ /**
+ * Helper method for the common usage of converting Strings to raw UTF-8 bytes.
+ */
+ public static byte[][] convertStringsToBytes(String... strings) {
+ final int length = strings.length;
+ byte[][] replacement = new byte[length][];
+ for (int i = 0; i < length; i++) {
+ replacement[i] = strings[i].getBytes(StandardCharsets.UTF_8);
+ }
+ return replacement;
+ }
+
+ public ByteTrieSearch(@NonNull byte[]... patterns) {
+ super(new ByteTrieNode(), patterns);
+ }
+}
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Event.kt b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Event.kt
new file mode 100644
index 0000000000..a4f76152ad
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Event.kt
@@ -0,0 +1,30 @@
+package app.revanced.extension.shared.utils
+
+/**
+ * generic event provider class
+ */
+class Event {
+ private val eventListeners = mutableSetOf<(T) -> Unit>()
+
+ operator fun plusAssign(observer: (T) -> Unit) {
+ addObserver(observer)
+ }
+
+ fun addObserver(observer: (T) -> Unit) {
+ eventListeners.add(observer)
+ }
+
+ operator fun minusAssign(observer: (T) -> Unit) {
+ removeObserver(observer)
+ }
+
+ private fun removeObserver(observer: (T) -> Unit) {
+ eventListeners.remove(observer)
+ }
+
+ operator fun invoke(value: T) {
+ for (observer in eventListeners)
+ observer.invoke(value)
+ }
+}
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/IntentUtils.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/IntentUtils.java
new file mode 100644
index 0000000000..6c15a67ee0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/IntentUtils.java
@@ -0,0 +1,44 @@
+package app.revanced.extension.shared.utils;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+public class IntentUtils extends Utils {
+
+ public static void launchExternalDownloader(@NonNull String content, @NonNull String downloaderPackageName) {
+ Intent intent = new Intent("android.intent.action.SEND");
+ intent.setType("text/plain");
+ intent.setPackage(downloaderPackageName);
+ intent.putExtra("android.intent.extra.TEXT", content);
+ launchIntent(intent);
+ }
+
+ private static void launchIntent(@NonNull Intent intent) {
+ // If possible, use the main activity as the context.
+ // Otherwise fall back on using the application context.
+ Context mContext = getActivity();
+ if (mContext == null) {
+ // Utils context is the application context, and not an activity context.
+ mContext = context;
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ }
+ mContext.startActivity(intent);
+ }
+
+ public static void launchView(@NonNull String content) {
+ launchView(content, null);
+ }
+
+ public static void launchView(@NonNull String content, @Nullable String packageName) {
+ Intent intent = new Intent("android.intent.action.VIEW", Uri.parse(content));
+ if (packageName != null) {
+ intent.setPackage(packageName);
+ }
+ launchIntent(intent);
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Logger.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Logger.java
new file mode 100644
index 0000000000..6583fc7ef8
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Logger.java
@@ -0,0 +1,126 @@
+package app.revanced.extension.shared.utils;
+
+import static app.revanced.extension.shared.settings.BaseSettings.ENABLE_DEBUG_LOGGING;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+
+public class Logger {
+
+ /**
+ * Log messages using lambdas.
+ */
+ public interface LogMessage {
+ @NonNull
+ String buildMessageString();
+
+ /**
+ * @return For outer classes, this returns {@link Class#getSimpleName()}.
+ * For static, inner, or anonymous classes, this returns the simple name of the enclosing class.
+ *
+ * For example, each of these classes return 'SomethingView':
+ *
+ * com.company.SomethingView
+ * com.company.SomethingView$StaticClass
+ * com.company.SomethingView$1
+ *
+ */
+ default String findOuterClassSimpleName() {
+ Class> selfClass = this.getClass();
+
+ String fullClassName = selfClass.getName();
+ final int dollarSignIndex = fullClassName.indexOf('$');
+ if (dollarSignIndex < 0) {
+ return selfClass.getSimpleName(); // Already an outer class.
+ }
+
+ // Class is inner, static, or anonymous.
+ // Parse the simple name full name.
+ // A class with no package returns index of -1, but incrementing gives index zero which is correct.
+ final int simpleClassNameStartIndex = fullClassName.lastIndexOf('.') + 1;
+ return fullClassName.substring(simpleClassNameStartIndex, dollarSignIndex);
+ }
+ }
+
+ private static final String REVANCED_LOG_PREFIX = "Extended: ";
+
+ /**
+ * Logs debug messages under the outer class name of the code calling this method.
+ * Whenever possible, the log string should be constructed entirely inside {@link LogMessage#buildMessageString()}
+ * so the performance cost of building strings is paid only if {@link BaseSettings#ENABLE_DEBUG_LOGGING} is enabled.
+ */
+ public static void printDebug(@NonNull LogMessage message) {
+ if (ENABLE_DEBUG_LOGGING.get()) {
+ Log.d(REVANCED_LOG_PREFIX + message.findOuterClassSimpleName(), message.buildMessageString());
+ }
+ }
+
+ /**
+ * Logs information messages using the outer class name of the code calling this method.
+ */
+ public static void printInfo(@NonNull LogMessage message) {
+ printInfo(message, null);
+ }
+
+ /**
+ * Logs information messages using the outer class name of the code calling this method.
+ */
+ public static void printInfo(@NonNull LogMessage message, @Nullable Exception ex) {
+ String logTag = REVANCED_LOG_PREFIX + message.findOuterClassSimpleName();
+ String logMessage = message.buildMessageString();
+ if (ex == null) {
+ Log.i(logTag, logMessage);
+ } else {
+ Log.i(logTag, logMessage, ex);
+ }
+ }
+
+ /**
+ * Logs exceptions under the outer class name of the code calling this method.
+ */
+ public static void printException(@NonNull LogMessage message) {
+ printException(message, null);
+ }
+
+ /**
+ * Logs exceptions under the outer class name of the code calling this method.
+ *
+ * If the calling code is showing it's own error toast,
+ * instead use {@link #printInfo(LogMessage, Exception)}
+ *
+ * @param message log message
+ * @param ex exception (optional)
+ */
+ public static void printException(@NonNull LogMessage message, @Nullable Throwable ex) {
+ String messageString = message.buildMessageString();
+ String outerClassSimpleName = message.findOuterClassSimpleName();
+ String logMessage = REVANCED_LOG_PREFIX + outerClassSimpleName;
+ if (ex == null) {
+ Log.e(logMessage, messageString);
+ } else {
+ Log.e(logMessage, messageString, ex);
+ }
+ }
+
+ /**
+ * Logging to use if {@link BaseSettings#ENABLE_DEBUG_LOGGING} or {@link Utils#getContext()} may not be initialized.
+ * Normally this method should not be used.
+ */
+ public static void initializationInfo(@NonNull Class> callingClass, @NonNull String message) {
+ Log.i(REVANCED_LOG_PREFIX + callingClass.getSimpleName(), message);
+ }
+
+ /**
+ * Logging to use if {@link BaseSettings#ENABLE_DEBUG_LOGGING} or {@link Utils#getContext()} may not be initialized.
+ * Normally this method should not be used.
+ */
+ public static void initializationException(@NonNull Class> callingClass, @NonNull String message,
+ @Nullable Exception ex) {
+ Log.e(REVANCED_LOG_PREFIX + callingClass.getSimpleName(), message, ex);
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/PackageUtils.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/PackageUtils.java
new file mode 100644
index 0000000000..7975ba063e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/PackageUtils.java
@@ -0,0 +1,91 @@
+package app.revanced.extension.shared.utils;
+
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+public class PackageUtils extends Utils {
+ private static String applicationLabel = "";
+ private static int smallestScreenWidthDp = 0;
+ private static String versionName = "";
+
+ public static String getApplicationLabel() {
+ return applicationLabel;
+ }
+
+ public static String getVersionName() {
+ return versionName;
+ }
+
+ public static boolean isPackageEnabled(@NonNull String packageName) {
+ try {
+ return context.getPackageManager().getApplicationInfo(packageName, 0).enabled;
+ } catch (PackageManager.NameNotFoundException ignored) {
+ }
+
+ return false;
+ }
+
+ public static boolean isTablet() {
+ return smallestScreenWidthDp >= 600;
+ }
+
+ public static void setApplicationLabel() {
+ final PackageInfo packageInfo = getPackageInfo();
+ if (packageInfo != null) {
+ final ApplicationInfo applicationInfo = packageInfo.applicationInfo;
+ if (applicationInfo != null) {
+ applicationLabel = (String) applicationInfo.loadLabel(getPackageManager());
+ }
+ }
+ }
+
+ public static void setSmallestScreenWidthDp() {
+ smallestScreenWidthDp = context.getResources().getConfiguration().smallestScreenWidthDp;
+ }
+
+ public static void setVersionName() {
+ final PackageInfo packageInfo = getPackageInfo();
+ if (packageInfo != null) {
+ versionName = packageInfo.versionName;
+ }
+ }
+
+ public static int getSmallestScreenWidthDp() {
+ return smallestScreenWidthDp;
+ }
+
+ // utils
+ @Nullable
+ private static PackageInfo getPackageInfo() {
+ try {
+ final PackageManager packageManager = getPackageManager();
+ final String packageName = context.getPackageName();
+ return isSDKAbove(33)
+ ? packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0))
+ : packageManager.getPackageInfo(packageName, 0);
+ } catch (PackageManager.NameNotFoundException e) {
+ Logger.printException(() -> "Failed to get package Info!" + e);
+ }
+ return null;
+ }
+
+ @NonNull
+ private static PackageManager getPackageManager() {
+ return context.getPackageManager();
+ }
+
+ public static boolean isVersionToLessThan(@NonNull String compareVersion, @NonNull String targetVersion) {
+ try {
+ final int compareVersionNumber = Integer.parseInt(compareVersion.replaceAll("\\.", ""));
+ final int targetVersionNumber = Integer.parseInt(targetVersion.replaceAll("\\.", ""));
+ return compareVersionNumber < targetVersionNumber;
+ } catch (NumberFormatException ex) {
+ Logger.printException(() -> "Failed to compare version: " + compareVersion + ", " + targetVersion, ex);
+ }
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/ResourceUtils.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/ResourceUtils.java
new file mode 100644
index 0000000000..55b7c1ac62
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/ResourceUtils.java
@@ -0,0 +1,186 @@
+package app.revanced.extension.shared.utils;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+
+import androidx.annotation.NonNull;
+
+/**
+ * @noinspection ALL
+ */
+public class ResourceUtils extends Utils {
+
+ private ResourceUtils() {
+ } // utility class
+
+ public static int getIdentifier(@NonNull String str, @NonNull ResourceType resourceType) {
+ return getIdentifier(str, resourceType, getContext());
+ }
+
+ public static int getIdentifier(@NonNull String str, @NonNull ResourceType resourceType,
+ @NonNull Context context) {
+ return getResources().getIdentifier(str, resourceType.getType(), context.getPackageName());
+ }
+
+ public static int getAnimIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.ANIM);
+ }
+
+ public static int getArrayIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.ARRAY);
+ }
+
+ public static int getAttrIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.ATTR);
+ }
+
+ public static int getColorIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.COLOR);
+ }
+
+ public static int getDimenIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.DIMEN);
+ }
+
+ public static int getDrawableIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.DRAWABLE);
+ }
+
+ public static int getFontIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.FONT);
+ }
+
+ public static int getIdIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.ID);
+ }
+
+ public static int getIntegerIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.INTEGER);
+ }
+
+ public static int getLayoutIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.LAYOUT);
+ }
+
+ public static int getMenuIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.MENU);
+ }
+
+ public static int getMipmapIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.MIPMAP);
+ }
+
+ public static int getRawIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.RAW);
+ }
+
+ public static int getStringIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.STRING);
+ }
+
+ public static int getStyleIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.STYLE);
+ }
+
+ public static int getXmlIdentifier(@NonNull String str) {
+ return getIdentifier(str, ResourceType.XML);
+ }
+
+ public static Animation getAnimation(@NonNull String str) {
+ int identifier = getAnimIdentifier(str);
+ if (identifier == 0) {
+ handleException(str, ResourceType.ANIM);
+ identifier = android.R.anim.fade_in;
+ }
+ return AnimationUtils.loadAnimation(getContext(), identifier);
+ }
+
+ public static int getColor(@NonNull String str) {
+ final int identifier = getColorIdentifier(str);
+ if (identifier == 0) {
+ handleException(str, ResourceType.COLOR);
+ return 0;
+ }
+ return getResources().getColor(identifier);
+ }
+
+ public static int getDimension(@NonNull String str) {
+ final int identifier = getDimenIdentifier(str);
+ if (identifier == 0) {
+ handleException(str, ResourceType.DIMEN);
+ return 0;
+ }
+ return getResources().getDimensionPixelSize(identifier);
+ }
+
+ public static Drawable getDrawable(@NonNull String str) {
+ final int identifier = getDrawableIdentifier(str);
+ if (identifier == 0) {
+ handleException(str, ResourceType.DRAWABLE);
+ return null;
+ }
+ return getResources().getDrawable(identifier);
+ }
+
+ public static String getString(@NonNull String str) {
+ final int identifier = getStringIdentifier(str);
+ if (identifier == 0) {
+ handleException(str, ResourceType.STRING);
+ return str;
+ }
+ return getResources().getString(identifier);
+ }
+
+ public static String[] getStringArray(@NonNull String str) {
+ final int identifier = getArrayIdentifier(str);
+ if (identifier == 0) {
+ handleException(str, ResourceType.ARRAY);
+ return new String[0];
+ }
+ return getResources().getStringArray(identifier);
+ }
+
+ public static int getInteger(@NonNull String str) {
+ final int identifier = getIntegerIdentifier(str);
+ if (identifier == 0) {
+ handleException(str, ResourceType.INTEGER);
+ return 0;
+ }
+ return getResources().getInteger(identifier);
+ }
+
+ private static void handleException(@NonNull String str, ResourceType resourceType) {
+ Logger.printException(() -> "R." + resourceType.getType() + "." + str + " is null");
+ }
+
+ public enum ResourceType {
+ ANIM("anim"),
+ ARRAY("array"),
+ ATTR("attr"),
+ COLOR("color"),
+ DIMEN("dimen"),
+ DRAWABLE("drawable"),
+ FONT("font"),
+ ID("id"),
+ INTEGER("integer"),
+ LAYOUT("layout"),
+ MENU("menu"),
+ MIPMAP("mipmap"),
+ RAW("raw"),
+ STRING("string"),
+ STYLE("style"),
+ XML("xml");
+
+ private final String type;
+
+ ResourceType(String type) {
+ this.type = type;
+ }
+
+ public final String getType() {
+ return type;
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/StringRef.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/StringRef.java
new file mode 100644
index 0000000000..f51b49ed09
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/StringRef.java
@@ -0,0 +1,135 @@
+package app.revanced.extension.shared.utils;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.res.Resources;
+
+import androidx.annotation.NonNull;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+@SuppressLint("DiscouragedApi")
+public class StringRef extends Utils {
+ private static Resources resources;
+
+ // must use a thread safe map, as this class is used both on and off the main thread
+ private static final Map strings = Collections.synchronizedMap(new HashMap<>());
+
+ /**
+ * Returns a cached instance.
+ * Should be used if the same String could be loaded more than once.
+ *
+ * @param id string resource name/id
+ * @see #sf(String)
+ */
+ @NonNull
+ public static StringRef sfc(@NonNull String id) {
+ StringRef ref = strings.get(id);
+ if (ref == null) {
+ ref = new StringRef(id);
+ strings.put(id, ref);
+ }
+ return ref;
+ }
+
+ /**
+ * Creates a new instance, but does not cache the value.
+ * Should be used for Strings that are loaded exactly once.
+ *
+ * @param id string resource name/id
+ * @see #sfc(String)
+ */
+ @NonNull
+ public static StringRef sf(@NonNull String id) {
+ return new StringRef(id);
+ }
+
+ /**
+ * Gets string value by string id, shorthand for sfc(id).toString()
+ *
+ * @param id string resource name/id
+ * @return String value from string.xml
+ */
+ @NonNull
+ public static String str(@NonNull String id) {
+ return sfc(id).toString();
+ }
+
+ /**
+ * Gets string value by string id, shorthand for sfc(id).toString()
and formats the string
+ * with given args.
+ *
+ * @param id string resource name/id
+ * @param args the args to format the string with
+ * @return String value from string.xml formatted with given args
+ */
+ @NonNull
+ public static String str(@NonNull String id, Object... args) {
+ return String.format(str(id), args);
+ }
+
+ /**
+ * Creates a StringRef object that'll not change it's value
+ *
+ * @param value value which toString() method returns when invoked on returned object
+ * @return Unique StringRef instance, its value will never change
+ */
+ @NonNull
+ public static StringRef constant(@NonNull String value) {
+ final StringRef ref = new StringRef(value);
+ ref.resolved = true;
+ return ref;
+ }
+
+ /**
+ * Shorthand for constant("")
+ * Its value always resolves to empty string
+ */
+ @SuppressLint("StaticFieldLeak")
+ @NonNull
+ public static final StringRef empty = constant("");
+
+ @NonNull
+ private String value;
+ private boolean resolved;
+
+ public StringRef(@NonNull String resName) {
+ this.value = resName;
+ }
+
+ @Override
+ @NonNull
+ public String toString() {
+ if (!resolved) {
+ try {
+ Context context = getContext();
+ if (resources == null) {
+ resources = getResources();
+ }
+ if (resources != null) {
+ value = ResourceUtils.getString(value);
+ resolved = true;
+ return value;
+ }
+ resources = context.getResources();
+ if (resources != null) {
+ final String packageName = context.getPackageName();
+ final int identifier = resources.getIdentifier(value, "string", packageName);
+ if (identifier == 0)
+ Logger.printException(() -> "Resource not found: " + value);
+ else
+ value = resources.getString(identifier);
+ resolved = true;
+ } else {
+ Logger.printException(() -> "Could not resolve resources!");
+ }
+ } catch (Exception ex) {
+ Logger.initializationException(StringRef.class, "Context is null!", ex);
+ }
+ }
+
+ return value;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/StringTrieSearch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/StringTrieSearch.java
new file mode 100644
index 0000000000..e4df4a57bf
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/StringTrieSearch.java
@@ -0,0 +1,38 @@
+package app.revanced.extension.shared.utils;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Text pattern searching using a prefix tree (trie).
+ */
+public final class StringTrieSearch extends TrieSearch {
+
+ private static final class StringTrieNode extends TrieNode {
+ StringTrieNode() {
+ super();
+ }
+
+ StringTrieNode(char nodeCharacterValue) {
+ super(nodeCharacterValue);
+ }
+
+ @Override
+ TrieNode createNode(char nodeValue) {
+ return new StringTrieNode(nodeValue);
+ }
+
+ @Override
+ char getCharValue(String text, int index) {
+ return text.charAt(index);
+ }
+
+ @Override
+ int getTextLength(String text) {
+ return text.length();
+ }
+ }
+
+ public StringTrieSearch(@NonNull String... patterns) {
+ super(new StringTrieNode(), patterns);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/TrieSearch.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/TrieSearch.java
new file mode 100644
index 0000000000..01ecf28c54
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/TrieSearch.java
@@ -0,0 +1,416 @@
+package app.revanced.extension.shared.utils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Searches for a group of different patterns using a trie (prefix tree).
+ * Can significantly speed up searching for multiple patterns.
+ */
+public abstract class TrieSearch {
+
+ public interface TriePatternMatchedCallback {
+ /**
+ * Called when a pattern is matched.
+ *
+ * @param textSearched Text that was searched.
+ * @param matchedStartIndex Start index of the search text, where the pattern was matched.
+ * @param matchedLength Length of the match.
+ * @param callbackParameter Optional parameter passed into {@link TrieSearch#matches(Object, Object)}.
+ * @return True, if the search should stop here.
+ * If false, searching will continue to look for other matches.
+ */
+ boolean patternMatched(T textSearched, int matchedStartIndex, int matchedLength, Object callbackParameter);
+ }
+
+ /**
+ * Represents a compressed tree path for a single pattern that shares no sibling nodes.
+ *
+ * For example, if a tree contains the patterns: "foobar", "football", "feet",
+ * it would contain 3 compressed paths of: "bar", "tball", "eet".
+ *
+ * And the tree would contain children arrays only for the first level containing 'f',
+ * the second level containing 'o',
+ * and the third level containing 'o'.
+ *
+ * This is done to reduce memory usage, which can be substantial if many long patterns are used.
+ */
+ private static final class TrieCompressedPath {
+ final T pattern;
+ final int patternStartIndex;
+ final int patternLength;
+ final TriePatternMatchedCallback callback;
+
+ TrieCompressedPath(T pattern, int patternStartIndex, int patternLength, TriePatternMatchedCallback callback) {
+ this.pattern = pattern;
+ this.patternStartIndex = patternStartIndex;
+ this.patternLength = patternLength;
+ this.callback = callback;
+ }
+
+ boolean matches(TrieNode enclosingNode, // Used only for the get character method.
+ T searchText, int searchTextLength, int searchTextIndex, Object callbackParameter) {
+ if (searchTextLength - searchTextIndex < patternLength - patternStartIndex) {
+ return false; // Remaining search text is shorter than the remaining leaf pattern and they cannot match.
+ }
+ for (int i = searchTextIndex, j = patternStartIndex; j < patternLength; i++, j++) {
+ if (enclosingNode.getCharValue(searchText, i) != enclosingNode.getCharValue(pattern, j)) {
+ return false;
+ }
+ }
+ return callback == null || callback.patternMatched(searchText,
+ searchTextIndex - patternStartIndex, patternLength, callbackParameter);
+ }
+ }
+
+ static abstract class TrieNode {
+ /**
+ * Dummy value used for root node. Value can be anything as it's never referenced.
+ */
+ private static final char ROOT_NODE_CHARACTER_VALUE = 0; // ASCII null character.
+
+ /**
+ * How much to expand the children array when resizing.
+ */
+ private static final int CHILDREN_ARRAY_INCREASE_SIZE_INCREMENT = 2;
+
+ /**
+ * Character this node represents.
+ * This field is ignored for the root node (which does not represent any character).
+ */
+ private final char nodeValue;
+
+ /**
+ * A compressed graph path that represents the remaining pattern characters of a single child node.
+ *
+ * If present then child array is always null, although callbacks for other
+ * end of patterns can also exist on this same node.
+ */
+ @Nullable
+ private TrieCompressedPath leaf;
+
+ /**
+ * All child nodes. Only present if no compressed leaf exist.
+ *
+ * Array is dynamically increased in size as needed,
+ * and uses perfect hashing for the elements it contains.
+ *
+ * So if the array contains a given character,
+ * the character will always map to the node with index: (character % arraySize).
+ *
+ * Elements not contained can collide with elements the array does contain,
+ * so must compare the nodes character value.
+ *
+ * Alternatively this array could be a sorted and densely packed array,
+ * and lookup is done using binary search.
+ * That would save a small amount of memory because there's no null children entries,
+ * but would give a worst case search of O(nlog(m)) where n is the number of
+ * characters in the searched text and m is the maximum size of the sorted character arrays.
+ * Using a hash table array always gives O(n) search time.
+ * The memory usage here is very small (all Litho filters use ~10KB of memory),
+ * so the more performant hash implementation is chosen.
+ */
+ @Nullable
+ private TrieNode[] children;
+
+ /**
+ * Callbacks for all patterns that end at this node.
+ */
+ @Nullable
+ private List> endOfPatternCallback;
+
+ TrieNode() {
+ this.nodeValue = ROOT_NODE_CHARACTER_VALUE;
+ }
+
+ TrieNode(char nodeCharacterValue) {
+ this.nodeValue = nodeCharacterValue;
+ }
+
+ /**
+ * @param pattern Pattern to add.
+ * @param patternIndex Current recursive index of the pattern.
+ * @param patternLength Length of the pattern.
+ * @param callback Callback, where a value of NULL indicates to always accept a pattern match.
+ */
+ private void addPattern(@NonNull T pattern, int patternIndex, int patternLength,
+ @Nullable TriePatternMatchedCallback callback) {
+ if (patternIndex == patternLength) { // Reached the end of the pattern.
+ if (endOfPatternCallback == null) {
+ endOfPatternCallback = new ArrayList<>(1);
+ }
+ endOfPatternCallback.add(callback);
+ return;
+ }
+ if (leaf != null) {
+ // Reached end of the graph and a leaf exist.
+ // Recursively call back into this method and push the existing leaf down 1 level.
+ if (children != null) throw new IllegalStateException();
+ //noinspection unchecked
+ children = new TrieNode[1];
+ TrieCompressedPath temp = leaf;
+ leaf = null;
+ addPattern(temp.pattern, temp.patternStartIndex, temp.patternLength, temp.callback);
+ // Continue onward and add the parameter pattern.
+ } else if (children == null) {
+ leaf = new TrieCompressedPath<>(pattern, patternIndex, patternLength, callback);
+ return;
+ }
+ final char character = getCharValue(pattern, patternIndex);
+ final int arrayIndex = hashIndexForTableSize(children.length, character);
+ TrieNode child = children[arrayIndex];
+ if (child == null) {
+ child = createNode(character);
+ children[arrayIndex] = child;
+ } else if (child.nodeValue != character) {
+ // Hash collision. Resize the table until perfect hashing is found.
+ child = createNode(character);
+ expandChildArray(child);
+ }
+ child.addPattern(pattern, patternIndex + 1, patternLength, callback);
+ }
+
+ /**
+ * Resizes the children table until all nodes hash to exactly one array index.
+ */
+ private void expandChildArray(TrieNode child) {
+ int replacementArraySize = Objects.requireNonNull(children).length;
+ while (true) {
+ replacementArraySize += CHILDREN_ARRAY_INCREASE_SIZE_INCREMENT;
+ //noinspection unchecked
+ TrieNode[] replacement = new TrieNode[replacementArraySize];
+ addNodeToArray(replacement, child);
+ boolean collision = false;
+ for (TrieNode existingChild : children) {
+ if (existingChild != null) {
+ if (!addNodeToArray(replacement, existingChild)) {
+ collision = true;
+ break;
+ }
+ }
+ }
+ if (collision) {
+ continue;
+ }
+ children = replacement;
+ return;
+ }
+ }
+
+ private static boolean addNodeToArray(TrieNode[] array, TrieNode childToAdd) {
+ final int insertIndex = hashIndexForTableSize(array.length, childToAdd.nodeValue);
+ if (array[insertIndex] != null) {
+ return false; // Collision.
+ }
+ array[insertIndex] = childToAdd;
+ return true;
+ }
+
+ private static int hashIndexForTableSize(int arraySize, char nodeValue) {
+ return nodeValue % arraySize;
+ }
+
+ /**
+ * This method is static and uses a loop to avoid all recursion.
+ * This is done for performance since the JVM does not optimize tail recursion.
+ *
+ * @param startNode Node to start the search from.
+ * @param searchText Text to search for patterns in.
+ * @param searchTextIndex Start index, inclusive.
+ * @param searchTextEndIndex End index, exclusive.
+ * @return If any pattern matches, and it's associated callback halted the search.
+ */
+ private static boolean matches(final TrieNode startNode, final T searchText,
+ int searchTextIndex, final int searchTextEndIndex,
+ final Object callbackParameter) {
+ TrieNode node = startNode;
+ int currentMatchLength = 0;
+
+ while (true) {
+ TrieCompressedPath leaf = node.leaf;
+ if (leaf != null && leaf.matches(startNode, searchText, searchTextEndIndex, searchTextIndex, callbackParameter)) {
+ return true; // Leaf exists and it matched the search text.
+ }
+ List> endOfPatternCallback = node.endOfPatternCallback;
+ if (endOfPatternCallback != null) {
+ final int matchStartIndex = searchTextIndex - currentMatchLength;
+ for (@Nullable TriePatternMatchedCallback callback : endOfPatternCallback) {
+ if (callback == null) {
+ return true; // No callback and all matches are valid.
+ }
+ if (callback.patternMatched(searchText, matchStartIndex, currentMatchLength, callbackParameter)) {
+ return true; // Callback confirmed the match.
+ }
+ }
+ }
+ TrieNode[] children = node.children;
+ if (children == null) {
+ return false; // Reached a graph end point and there's no further patterns to search.
+ }
+ if (searchTextIndex == searchTextEndIndex) {
+ return false; // Reached end of the search text and found no matches.
+ }
+
+ // Use the start node to reduce VM method lookup, since all nodes are the same class type.
+ final char character = startNode.getCharValue(searchText, searchTextIndex);
+ final int arrayIndex = hashIndexForTableSize(children.length, character);
+ TrieNode child = children[arrayIndex];
+ if (child == null || child.nodeValue != character) {
+ return false;
+ }
+
+ node = child;
+ searchTextIndex++;
+ currentMatchLength++;
+ }
+ }
+
+ /**
+ * Gives an approximate memory usage.
+ *
+ * @return Estimated number of memory pointers used, starting from this node and including all children.
+ */
+ private int estimatedNumberOfPointersUsed() {
+ int numberOfPointers = 4; // Number of fields in this class.
+ if (leaf != null) {
+ numberOfPointers += 4; // Number of fields in leaf node.
+ }
+ if (endOfPatternCallback != null) {
+ numberOfPointers += endOfPatternCallback.size();
+ }
+ if (children != null) {
+ numberOfPointers += children.length;
+ for (TrieNode child : children) {
+ if (child != null) {
+ numberOfPointers += child.estimatedNumberOfPointersUsed();
+ }
+ }
+ }
+ return numberOfPointers;
+ }
+
+ abstract TrieNode createNode(char nodeValue);
+
+ abstract char getCharValue(T text, int index);
+
+ abstract int getTextLength(T text);
+ }
+
+ /**
+ * Root node, and it's children represent the first pattern characters.
+ */
+ private final TrieNode root;
+
+ /**
+ * Patterns to match.
+ */
+ private final List patterns = new ArrayList<>();
+
+ @SafeVarargs
+ TrieSearch(@NonNull TrieNode root, @NonNull T... patterns) {
+ this.root = Objects.requireNonNull(root);
+ addPatterns(patterns);
+ }
+
+ @SafeVarargs
+ public final void addPatterns(@NonNull T... patterns) {
+ for (T pattern : patterns) {
+ addPattern(pattern);
+ }
+ }
+
+ /**
+ * Adds a pattern that will always return a positive match if found.
+ *
+ * @param pattern Pattern to add. Calling this with a zero length pattern does nothing.
+ */
+ public void addPattern(@NonNull T pattern) {
+ addPattern(pattern, root.getTextLength(pattern), null);
+ }
+
+ /**
+ * @param pattern Pattern to add. Calling this with a zero length pattern does nothing.
+ * @param callback Callback to determine if searching should halt when a match is found.
+ */
+ public void addPattern(@NonNull T pattern, @NonNull TriePatternMatchedCallback callback) {
+ addPattern(pattern, root.getTextLength(pattern), Objects.requireNonNull(callback));
+ }
+
+ void addPattern(@NonNull T pattern, int patternLength, @Nullable TriePatternMatchedCallback callback) {
+ if (patternLength == 0) return; // Nothing to match
+
+ patterns.add(pattern);
+ root.addPattern(pattern, 0, patternLength, callback);
+ }
+
+ public final boolean matches(@NonNull T textToSearch) {
+ return matches(textToSearch, 0);
+ }
+
+ public boolean matches(@NonNull T textToSearch, @NonNull Object callbackParameter) {
+ return matches(textToSearch, 0, root.getTextLength(textToSearch),
+ Objects.requireNonNull(callbackParameter));
+ }
+
+ public boolean matches(@NonNull T textToSearch, int startIndex) {
+ return matches(textToSearch, startIndex, root.getTextLength(textToSearch));
+ }
+
+ public final boolean matches(@NonNull T textToSearch, int startIndex, int endIndex) {
+ return matches(textToSearch, startIndex, endIndex, null);
+ }
+
+ /**
+ * Searches through text, looking for any substring that matches any pattern in this tree.
+ *
+ * @param textToSearch Text to search through.
+ * @param startIndex Index to start searching, inclusive value.
+ * @param endIndex Index to stop matching, exclusive value.
+ * @param callbackParameter Optional parameter passed to the callbacks.
+ * @return If any pattern matched, and it's callback halted searching.
+ */
+ public boolean matches(@NonNull T textToSearch, int startIndex, int endIndex, @Nullable Object callbackParameter) {
+ return matches(textToSearch, root.getTextLength(textToSearch), startIndex, endIndex, callbackParameter);
+ }
+
+ private boolean matches(@NonNull T textToSearch, int textToSearchLength, int startIndex, int endIndex,
+ @Nullable Object callbackParameter) {
+ if (endIndex > textToSearchLength) {
+ throw new IllegalArgumentException("endIndex: " + endIndex
+ + " is greater than texToSearchLength: " + textToSearchLength);
+ }
+ if (patterns.isEmpty()) {
+ return false; // No patterns were added.
+ }
+ for (int i = startIndex; i < endIndex; i++) {
+ if (TrieNode.matches(root, textToSearch, i, endIndex, callbackParameter)) return true;
+ }
+ return false;
+ }
+
+ /**
+ * @return Estimated memory size (in kilobytes) of this instance.
+ */
+ public int getEstimatedMemorySize() {
+ if (patterns.isEmpty()) {
+ return 0;
+ }
+ // Assume the device has less than 32GB of ram (and can use pointer compression),
+ // or the device is 32-bit.
+ final int numberOfBytesPerPointer = 4;
+ return (int) Math.ceil((numberOfBytesPerPointer * root.estimatedNumberOfPointersUsed()) / 1024.0);
+ }
+
+ public int numberOfPatterns() {
+ return patterns.size();
+ }
+
+ public List getPatterns() {
+ return Collections.unmodifiableList(patterns);
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Utils.java b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Utils.java
new file mode 100644
index 0000000000..aaf9f21c78
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/shared/utils/Utils.java
@@ -0,0 +1,737 @@
+package app.revanced.extension.shared.utils;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Fragment;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.preference.Preference;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceScreen;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewParent;
+import android.widget.FrameLayout;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.Toast;
+import android.widget.Toolbar;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.lang.ref.WeakReference;
+import java.text.Bidi;
+import java.time.Duration;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+import kotlin.text.Regex;
+
+@SuppressWarnings("deprecation")
+public class Utils {
+
+ private static WeakReference activityRef = new WeakReference<>(null);
+
+ @SuppressLint("StaticFieldLeak")
+ public static Context context;
+
+ private static Resources resources;
+
+ protected Utils() {
+ } // utility class
+
+ public static void clickView(View view) {
+ if (view == null) return;
+ view.callOnClick();
+ }
+
+ /**
+ * Hide a view by setting its layout height and width to 1dp.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static void hideViewBy0dpUnderCondition(BooleanSetting condition, View view) {
+ hideViewBy0dpUnderCondition(condition.get(), view);
+ }
+
+ public static void hideViewBy0dpUnderCondition(boolean enabled, View view) {
+ if (!enabled) return;
+
+ hideViewByLayoutParams(view);
+ }
+
+ /**
+ * Hide a view by setting its visibility to GONE.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static void hideViewUnderCondition(BooleanSetting condition, View view) {
+ hideViewUnderCondition(condition.get(), view);
+ }
+
+ /**
+ * Hide a view by setting its visibility to GONE.
+ *
+ * @param condition The setting to check for hiding the view.
+ * @param view The view to hide.
+ */
+ public static void hideViewUnderCondition(boolean condition, View view) {
+ if (!condition) return;
+ if (view == null) return;
+
+ view.setVisibility(View.GONE);
+ }
+
+ @SuppressWarnings("unused")
+ public static void hideViewByRemovingFromParentUnderCondition(BooleanSetting condition, View view) {
+ hideViewByRemovingFromParentUnderCondition(condition.get(), view);
+ }
+
+ public static void hideViewByRemovingFromParentUnderCondition(boolean condition, View view) {
+ if (!condition) return;
+ if (view == null) return;
+ if (!(view.getParent() instanceof ViewGroup viewGroup))
+ return;
+
+ viewGroup.removeView(view);
+ }
+
+ /**
+ * General purpose pool for network calls and other background tasks.
+ * All tasks run at max thread priority.
+ */
+ private static final ThreadPoolExecutor backgroundThreadPool = new ThreadPoolExecutor(
+ 3, // 3 threads always ready to go
+ Integer.MAX_VALUE,
+ 10, // For any threads over the minimum, keep them alive 10 seconds after they go idle
+ TimeUnit.SECONDS,
+ new SynchronousQueue<>(),
+ r -> { // ThreadFactory
+ Thread t = new Thread(r);
+ t.setPriority(Thread.MAX_PRIORITY); // run at max priority
+ return t;
+ });
+
+ public static void runOnBackgroundThread(@NonNull Runnable task) {
+ backgroundThreadPool.execute(task);
+ }
+
+ @NonNull
+ public static Future submitOnBackgroundThread(@NonNull Callable call) {
+ return backgroundThreadPool.submit(call);
+ }
+
+
+ public static boolean containsAny(@NonNull String value, @NonNull String... targets) {
+ return indexOfFirstFound(value, targets) >= 0;
+ }
+
+ public static int indexOfFirstFound(@NonNull String value, @NonNull String... targets) {
+ for (String string : targets) {
+ if (!string.isEmpty()) {
+ final int indexOf = value.indexOf(string);
+ if (indexOf >= 0) return indexOf;
+ }
+ }
+ return -1;
+ }
+
+ public interface MatchFilter {
+ boolean matches(T object);
+ }
+
+ public static R getChildView(@NonNull Activity activity, @NonNull String str) {
+ final View decorView = activity.getWindow().getDecorView();
+ return getChildView(decorView, str);
+ }
+
+ /**
+ * @noinspection unchecked
+ */
+ public static R getChildView(@NonNull View view, @NonNull String str) {
+ view = view.findViewById(ResourceUtils.getIdIdentifier(str));
+ if (view != null) {
+ return (R) view;
+ } else {
+ throw new IllegalArgumentException("View with name" + str + " not found");
+ }
+ }
+
+ /**
+ * @param searchRecursively If children ViewGroups should also be
+ * recursively searched using depth first search.
+ * @return The first child view that matches the filter.
+ */
+ @Nullable
+ public static T getChildView(@NonNull ViewGroup viewGroup, boolean searchRecursively,
+ @NonNull MatchFilter filter) {
+ for (int i = 0, childCount = viewGroup.getChildCount(); i < childCount; i++) {
+ View childAt = viewGroup.getChildAt(i);
+ if (filter.matches(childAt)) {
+ //noinspection unchecked
+ return (T) childAt;
+ }
+ // Must do recursive after filter check, in case the filter is looking for a ViewGroup.
+ if (searchRecursively && childAt instanceof ViewGroup) {
+ T match = getChildView((ViewGroup) childAt, true, filter);
+ if (match != null) return match;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * @return The first child view that matches the filter.
+ * @noinspection rawtypes, unchecked
+ */
+ @Nullable
+ public static T getChildView(@NonNull ViewGroup viewGroup, @NonNull MatchFilter filter) {
+ for (int i = 0, childCount = viewGroup.getChildCount(); i < childCount; i++) {
+ View childAt = viewGroup.getChildAt(i);
+ if (filter.matches(childAt)) {
+ return (T) childAt;
+ }
+ }
+ return null;
+ }
+
+ @Nullable
+ public static ViewParent getParentView(@NonNull View view, int nthParent) {
+ ViewParent parent = view.getParent();
+
+ int currentDepth = 0;
+ while (++currentDepth < nthParent && parent != null) {
+ parent = parent.getParent();
+ }
+
+ if (currentDepth == nthParent) {
+ return parent;
+ }
+
+ final int finalDepthLog = currentDepth;
+ final ViewParent finalParent = parent;
+ Logger.printDebug(() -> "Could not find parent view of depth: " + nthParent
+ + " and instead found at: " + finalDepthLog + " view: " + finalParent);
+ return null;
+ }
+
+ public static void restartApp(@NonNull Context mContext) {
+ String packageName = mContext.getPackageName();
+ Intent intent = mContext.getPackageManager().getLaunchIntentForPackage(packageName);
+ if (intent == null) return;
+ Intent mainIntent = Intent.makeRestartActivityTask(intent.getComponent());
+ // Required for API 34 and later
+ // Ref: https://developer.android.com/about/versions/14/behavior-changes-14#safer-intents
+ mainIntent.setPackage(packageName);
+ if (mContext instanceof Activity mActivity) {
+ mActivity.finishAndRemoveTask();
+ }
+ mContext.startActivity(mainIntent);
+ System.runFinalizersOnExit(true);
+ System.exit(0);
+ }
+
+ public static Activity getActivity() {
+ return activityRef.get();
+ }
+
+ public static Context getContext() {
+ if (context == null) {
+ Logger.initializationException(Utils.class, "Context is null, returning null!", null);
+ }
+ return context;
+ }
+
+ public static Resources getResources() {
+ if (resources == null) {
+ return getLocalizedContextAndSetResources(getContext()).getResources();
+ } else {
+ return resources;
+ }
+ }
+
+ /**
+ * Compare MainActivity's Locale and Context's Locale.
+ * If the Locale of MainActivity and the Locale of Context are different, the Locale of MainActivity is applied.
+ *
+ * If Locale changes, resources should also change and be saved locally.
+ * Otherwise, {@link ResourceUtils#getString(String)} will be updated to the incorrect language.
+ *
+ * @param mContext Context to check locale.
+ * @return Context with locale applied.
+ */
+ public static Context getLocalizedContextAndSetResources(Context mContext) {
+ Activity mActivity = activityRef.get();
+ if (mActivity == null) {
+ return mContext;
+ }
+
+ // Locale of MainActivity.
+ Locale applicationLocale;
+
+ // Locale of Context.
+ Locale contextLocale;
+
+ if (isSDKAbove(24)) {
+ applicationLocale = mActivity.getResources().getConfiguration().getLocales().get(0);
+ contextLocale = mContext.getResources().getConfiguration().getLocales().get(0);
+ } else {
+ applicationLocale = mActivity.getResources().getConfiguration().locale;
+ contextLocale = mContext.getResources().getConfiguration().locale;
+ }
+
+ // If they are identical, no need to override them.
+ if (applicationLocale == contextLocale) {
+ resources = mActivity.getResources();
+ return mContext;
+ }
+
+ // If they are different, overrides the Locale of the Context and resource.
+ Locale.setDefault(applicationLocale);
+ Configuration configuration = new Configuration(mContext.getResources().getConfiguration());
+ configuration.setLocale(applicationLocale);
+ Context localizedContext = mContext.createConfigurationContext(configuration);
+ resources = localizedContext.getResources();
+ return localizedContext;
+ }
+
+ public static void setActivity(Activity mainActivity) {
+ activityRef = new WeakReference<>(mainActivity);
+ }
+
+ public static void setContext(@Nullable Context appContext) {
+ // Typically, Context is invoked in the constructor method, so it is not null.
+ // Since some are invoked from methods other than the constructor method,
+ // it may be necessary to check whether Context is null.
+ if (appContext == null) {
+ return;
+ }
+
+ context = appContext;
+
+ // In some apps like TikTok, the Setting classes can load in weird orders due to cyclic class dependencies.
+ // Calling the regular printDebug method here can cause a Settings context null pointer exception,
+ // even though the context is already set before the call.
+ //
+ // The initialization logger methods do not directly or indirectly
+ // reference the Context or any Settings and are unaffected by this problem.
+ //
+ // Info level also helps debug if a patch hook is called before
+ // the context is set since debug logging is off by default.
+ Logger.initializationInfo(Utils.class, "Set context: " + appContext);
+ }
+
+ public static void setClipboard(@NonNull String text) {
+ setClipboard(text, null);
+ }
+
+ public static void setClipboard(@NonNull String text, @Nullable String toastMessage) {
+ if (!(context.getSystemService(Context.CLIPBOARD_SERVICE) instanceof ClipboardManager clipboard))
+ return;
+ android.content.ClipData clip = android.content.ClipData.newPlainText("ReVanced", text);
+ clipboard.setPrimaryClip(clip);
+
+ // Do not show a toast if using Android 13+ as it shows it's own toast.
+ // But if the user copied with a timestamp then show a toast.
+ // Unfortunately this will show 2 toasts on Android 13+, but no way around this.
+ if (isSDKAbove(33) || toastMessage == null) return;
+ showToastShort(toastMessage);
+ }
+
+ public static String getFormattedTimeStamp(long videoTime) {
+ return "'" + videoTime +
+ "' (" +
+ getTimeStamp(videoTime) +
+ ")\n";
+ }
+
+ @SuppressLint("DefaultLocale")
+ public static String getTimeStamp(long time) {
+ long hours;
+ long minutes;
+ long seconds;
+
+ if (isSDKAbove(26)) {
+ final Duration duration = Duration.ofMillis(time);
+
+ hours = duration.toHours();
+ minutes = duration.toMinutes() % 60;
+ seconds = duration.getSeconds() % 60;
+ } else {
+ final long currentVideoTimeInSeconds = time / 1000;
+
+ hours = currentVideoTimeInSeconds / (60 * 60);
+ minutes = (currentVideoTimeInSeconds / 60) % 60;
+ seconds = currentVideoTimeInSeconds % 60;
+ }
+
+ if (hours > 0) {
+ return String.format("%02d:%02d:%02d", hours, minutes, seconds);
+ } else {
+ return String.format("%02d:%02d", minutes, seconds);
+ }
+ }
+
+ public static void setEditTextDialogTheme(final AlertDialog.Builder builder) {
+ setEditTextDialogTheme(builder, false);
+ }
+
+ /**
+ * If {@link Fragment} uses [Android library] rather than [AndroidX library],
+ * the Dialog theme corresponding to [Android library] should be used.
+ *
+ * If not, the following issues will occur:
+ * ReVanced/revanced-patches#3061
+ *
+ * To prevent these issues, apply the Dialog theme corresponding to [Android library].
+ *
+ * @param builder Alertdialog builder to apply theme to.
+ * When used in a method containing an override, it must be called before 'super'.
+ * @param maxWidth Whether to use alertdialog as max width.
+ * It is used when there is a lot of content to show, such as an import/export dialog.
+ */
+ public static void setEditTextDialogTheme(final AlertDialog.Builder builder, boolean maxWidth) {
+ final String styleIdentifier = maxWidth
+ ? "revanced_edit_text_dialog_max_width_style"
+ : "revanced_edit_text_dialog_style";
+ final int editTextDialogStyle = ResourceUtils.getStyleIdentifier(styleIdentifier);
+ if (editTextDialogStyle != 0) {
+ builder.getContext().setTheme(editTextDialogStyle);
+ }
+ }
+
+ public static AlertDialog.Builder getEditTextDialogBuilder(final Context context) {
+ return getEditTextDialogBuilder(context, false);
+ }
+
+ public static AlertDialog.Builder getEditTextDialogBuilder(final Context context, boolean maxWidth) {
+ final AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ setEditTextDialogTheme(builder, maxWidth);
+ return builder;
+ }
+
+ @Nullable
+ private static Boolean isRightToLeftTextLayout;
+
+ /**
+ * If the device language uses right to left text layout (hebrew, arabic, etc)
+ */
+ public static boolean isRightToLeftTextLayout() {
+ if (isRightToLeftTextLayout == null) {
+ String displayLanguage = Locale.getDefault().getDisplayLanguage();
+ isRightToLeftTextLayout = new Bidi(displayLanguage, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT).isRightToLeft();
+ }
+ return isRightToLeftTextLayout;
+ }
+
+ /**
+ * @return if the text contains at least 1 number character,
+ * including any unicode numbers such as Arabic.
+ */
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ public static boolean containsNumber(@NonNull CharSequence text) {
+ for (int index = 0, length = text.length(); index < length; ) {
+ final int codePoint = Character.codePointAt(text, index);
+ if (Character.isDigit(codePoint)) {
+ return true;
+ }
+ index += Character.charCount(codePoint);
+ }
+
+ return false;
+ }
+
+ /**
+ * @return whether the device's API level is higher than a specific SDK version.
+ */
+ public static boolean isSDKAbove(int sdk) {
+ return Build.VERSION.SDK_INT >= sdk;
+ }
+
+ /**
+ * Safe to call from any thread
+ */
+ public static void showToastShort(@NonNull String messageToToast) {
+ showToast(messageToToast, Toast.LENGTH_SHORT);
+ }
+
+ /**
+ * Safe to call from any thread
+ */
+ public static void showToastLong(@NonNull String messageToToast) {
+ showToast(messageToToast, Toast.LENGTH_LONG);
+ }
+
+ private static void showToast(@NonNull String messageToToast, int toastDuration) {
+ Objects.requireNonNull(messageToToast);
+ runOnMainThreadNowOrLater(() -> {
+ if (context == null) {
+ Logger.initializationException(Utils.class, "Cannot show toast (context is null): " + messageToToast, null);
+ } else {
+ Logger.printDebug(() -> "Showing toast: " + messageToToast);
+ Toast.makeText(context, messageToToast, toastDuration).show();
+ }
+ }
+ );
+ }
+
+ /**
+ * Automatically logs any exceptions the runnable throws.
+ *
+ * @see #runOnMainThreadNowOrLater(Runnable)
+ */
+ public static void runOnMainThread(@NonNull Runnable runnable) {
+ runOnMainThreadDelayed(runnable, 0);
+ }
+
+ /**
+ * Automatically logs any exceptions the runnable throws
+ */
+ public static void runOnMainThreadDelayed(@NonNull Runnable runnable, long delayMillis) {
+ Runnable loggingRunnable = () -> {
+ try {
+ runnable.run();
+ } catch (Exception ex) {
+ Logger.printException(() -> runnable.getClass().getSimpleName() + ": " + ex.getMessage(), ex);
+ }
+ };
+ new Handler(Looper.getMainLooper()).postDelayed(loggingRunnable, delayMillis);
+ }
+
+ /**
+ * If called from the main thread, the code is run immediately.
+ * If called off the main thread, this is the same as {@link #runOnMainThread(Runnable)}.
+ */
+ public static void runOnMainThreadNowOrLater(@NonNull Runnable runnable) {
+ if (isCurrentlyOnMainThread()) {
+ runnable.run();
+ } else {
+ runOnMainThread(runnable);
+ }
+ }
+
+ /**
+ * @return if the calling thread is on the main thread
+ */
+ public static boolean isCurrentlyOnMainThread() {
+ if (isSDKAbove(23)) {
+ return Looper.getMainLooper().isCurrentThread();
+ } else {
+ return Looper.getMainLooper().getThread() == Thread.currentThread();
+ }
+ }
+
+ /**
+ * @throws IllegalStateException if the calling thread is _off_ the main thread
+ */
+ public static void verifyOnMainThread() throws IllegalStateException {
+ if (!isCurrentlyOnMainThread()) {
+ throw new IllegalStateException("Must call _on_ the main thread");
+ }
+ }
+
+ /**
+ * @throws IllegalStateException if the calling thread is _on_ the main thread
+ */
+ public static void verifyOffMainThread() throws IllegalStateException {
+ if (isCurrentlyOnMainThread()) {
+ throw new IllegalStateException("Must call _off_ the main thread");
+ }
+ }
+
+ public enum NetworkType {
+ MOBILE("mobile"),
+ WIFI("wifi"),
+ NONE("none");
+
+ private final String name;
+
+ NetworkType(String name) {
+ this.name = name;
+ }
+
+ public String getName() {
+ return name;
+ }
+ }
+
+ public static boolean isNetworkNotConnected() {
+ final NetworkType networkType = getNetworkType();
+ return networkType == NetworkType.NONE;
+ }
+
+ @SuppressLint("MissingPermission") // permission already included in YouTube
+ public static NetworkType getNetworkType() {
+ if (context == null || !(context.getSystemService(Context.CONNECTIVITY_SERVICE) instanceof ConnectivityManager cm))
+ return NetworkType.NONE;
+
+ final NetworkInfo networkInfo = cm.getActiveNetworkInfo();
+
+ if (networkInfo == null || !networkInfo.isConnected())
+ return NetworkType.NONE;
+
+ return switch (networkInfo.getType()) {
+ case ConnectivityManager.TYPE_MOBILE, ConnectivityManager.TYPE_BLUETOOTH ->
+ NetworkType.MOBILE;
+ default -> NetworkType.WIFI;
+ };
+ }
+
+ /**
+ * Hide a view by setting its layout params to 0x0
+ *
+ * @param view The view to hide.
+ */
+ public static void hideViewByLayoutParams(View view) {
+ if (view == null) return;
+
+ if (view instanceof LinearLayout) {
+ LinearLayout.LayoutParams layoutParams = new LinearLayout.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams);
+ } else if (view instanceof FrameLayout) {
+ FrameLayout.LayoutParams layoutParams2 = new FrameLayout.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams2);
+ } else if (view instanceof RelativeLayout) {
+ RelativeLayout.LayoutParams layoutParams3 = new RelativeLayout.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams3);
+ } else if (view instanceof Toolbar) {
+ Toolbar.LayoutParams layoutParams4 = new Toolbar.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams4);
+ } else if (view instanceof ViewGroup) {
+ ViewGroup.LayoutParams layoutParams5 = new ViewGroup.LayoutParams(0, 0);
+ view.setLayoutParams(layoutParams5);
+ } else {
+ ViewGroup.LayoutParams params = view.getLayoutParams();
+ params.width = 0;
+ params.height = 0;
+ view.setLayoutParams(params);
+ }
+ }
+
+ public static void hideViewGroupByMarginLayoutParams(ViewGroup viewGroup) {
+ // Rest of the implementation added by patch.
+ viewGroup.setVisibility(View.GONE);
+ }
+
+ /**
+ * {@link PreferenceScreen} and {@link PreferenceGroup} sorting styles.
+ */
+ private enum Sort {
+ /**
+ * Sort by the localized preference title.
+ */
+ BY_TITLE("_sort_by_title"),
+
+ /**
+ * Sort by the preference keys.
+ */
+ BY_KEY("_sort_by_key"),
+
+ /**
+ * Unspecified sorting.
+ */
+ UNSORTED("_sort_by_unsorted");
+
+ final String keySuffix;
+
+ Sort(String keySuffix) {
+ this.keySuffix = keySuffix;
+ }
+
+ @NonNull
+ static Sort fromKey(@Nullable String key, @NonNull Sort defaultSort) {
+ if (key != null) {
+ for (Sort sort : values()) {
+ if (key.endsWith(sort.keySuffix)) {
+ return sort;
+ }
+ }
+ }
+ return defaultSort;
+ }
+ }
+
+ private static final Regex punctuationRegex = new Regex("\\p{P}+");
+
+ /**
+ * Strips all punctuation and converts to lower case. A null parameter returns an empty string.
+ */
+ public static String removePunctuationConvertToLowercase(@Nullable CharSequence original) {
+ if (original == null) return "";
+ return punctuationRegex.replace(original, "").toLowerCase();
+ }
+
+ /**
+ * Sort a PreferenceGroup and all it's sub groups by title or key.
+ *
+ * Sort order is determined by the preferences key {@link Sort} suffix.
+ *
+ * If a preference has no key or no {@link Sort} suffix,
+ * then the preferences are left unsorted.
+ */
+ @SuppressWarnings("deprecation")
+ public static void sortPreferenceGroups(@NonNull PreferenceGroup group) {
+ Sort groupSort = Sort.fromKey(group.getKey(), Sort.UNSORTED);
+ SortedMap preferences = new TreeMap<>();
+
+ for (int i = 0, prefCount = group.getPreferenceCount(); i < prefCount; i++) {
+ Preference preference = group.getPreference(i);
+
+ final Sort preferenceSort;
+ if (preference instanceof PreferenceGroup preferenceGroup) {
+ sortPreferenceGroups(preferenceGroup);
+ preferenceSort = groupSort; // Sort value for groups is for it's content, not itself.
+ } else {
+ // Allow individual preferences to set a key sorting.
+ // Used to force a preference to the top or bottom of a group.
+ preferenceSort = Sort.fromKey(preference.getKey(), groupSort);
+ }
+
+ final String sortValue;
+ switch (preferenceSort) {
+ case BY_TITLE ->
+ sortValue = removePunctuationConvertToLowercase(preference.getTitle());
+ case BY_KEY -> sortValue = preference.getKey();
+ case UNSORTED -> {
+ continue; // Keep original sorting.
+ }
+ default -> throw new IllegalStateException();
+ }
+
+ preferences.put(sortValue, preference);
+ }
+
+ int index = 0;
+ for (Preference pref : preferences.values()) {
+ int order = index++;
+
+ // If the preference is a PreferenceScreen or is an intent preference, move to the top.
+ if (pref instanceof PreferenceScreen || pref.getIntent() != null) {
+ // Arbitrary high number.
+ order -= 1000;
+ }
+
+ pref.setOrder(order);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ads/AdsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ads/AdsPatch.java
new file mode 100644
index 0000000000..9eb1aa7b1e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/ads/AdsPatch.java
@@ -0,0 +1,46 @@
+package app.revanced.extension.youtube.patches.ads;
+
+import static app.revanced.extension.shared.utils.Utils.hideViewBy0dpUnderCondition;
+
+import android.view.View;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class AdsPatch {
+ private static final boolean hideGeneralAdsEnabled = Settings.HIDE_GENERAL_ADS.get();
+ private static final boolean hideGetPremiumAdsEnabled = Settings.HIDE_GET_PREMIUM.get();
+ private static final boolean hideVideoAdsEnabled = Settings.HIDE_VIDEO_ADS.get();
+
+ /**
+ * Injection point.
+ * Hide the view, which shows ads in the homepage.
+ *
+ * @param view The view, which shows ads.
+ */
+ public static void hideAdAttributionView(View view) {
+ hideViewBy0dpUnderCondition(hideGeneralAdsEnabled, view);
+ }
+
+ public static boolean hideGetPremium() {
+ return hideGetPremiumAdsEnabled;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean hideVideoAds() {
+ return !hideVideoAdsEnabled;
+ }
+
+ /**
+ * Injection point.
+ *
+ * Only used by old clients.
+ * It is presumed to have been deprecated, and if it is confirmed that it is no longer used, remove it.
+ */
+ public static boolean hideVideoAds(boolean original) {
+ return !hideVideoAdsEnabled && original;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/alternativethumbnails/AlternativeThumbnailsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/alternativethumbnails/AlternativeThumbnailsPatch.java
new file mode 100644
index 0000000000..aa97508533
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/alternativethumbnails/AlternativeThumbnailsPatch.java
@@ -0,0 +1,721 @@
+package app.revanced.extension.youtube.patches.alternativethumbnails;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.youtube.settings.Settings.ALT_THUMBNAIL_HOME;
+import static app.revanced.extension.youtube.settings.Settings.ALT_THUMBNAIL_LIBRARY;
+import static app.revanced.extension.youtube.settings.Settings.ALT_THUMBNAIL_PLAYER;
+import static app.revanced.extension.youtube.settings.Settings.ALT_THUMBNAIL_SEARCH;
+import static app.revanced.extension.youtube.settings.Settings.ALT_THUMBNAIL_SUBSCRIPTIONS;
+import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
+
+import android.net.Uri;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.chromium.net.UrlRequest;
+import org.chromium.net.UrlResponseInfo;
+import org.chromium.net.impl.CronetUrlRequest;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.RootView;
+
+/**
+ * @noinspection ALL
+ * Alternative YouTube thumbnails.
+ *
+ * Can show YouTube provided screen captures of beginning/middle/end of the video.
+ * (ie: sd1.jpg, sd2.jpg, sd3.jpg).
+ *
+ * Or can show crowd-sourced thumbnails provided by DeArrow (... ).
+ *
+ * Or can use DeArrow and fall back to screen captures if DeArrow is not available.
+ *
+ * Has an additional option to use 'fast' video still thumbnails,
+ * where it forces sd thumbnail quality and skips verifying if the alt thumbnail image exists.
+ * The UI loading time will be the same or better than using original thumbnails,
+ * but thumbnails will initially fail to load for all live streams, unreleased, and occasionally very old videos.
+ * If a failed thumbnail load is reloaded (ie: scroll off, then on screen), then the original thumbnail
+ * is reloaded instead. Fast thumbnails requires using SD or lower thumbnail resolution,
+ * because a noticeable number of videos do not have hq720 and too much fail to load.
+ */
+public final class AlternativeThumbnailsPatch {
+
+ // These must be class declarations if declared here,
+ // otherwise the app will not load due to cyclic initialization errors.
+ public static final class DeArrowAvailability implements Setting.Availability {
+ public static boolean usingDeArrowAnywhere() {
+ return ALT_THUMBNAIL_HOME.get().useDeArrow
+ || ALT_THUMBNAIL_SUBSCRIPTIONS.get().useDeArrow
+ || ALT_THUMBNAIL_LIBRARY.get().useDeArrow
+ || ALT_THUMBNAIL_PLAYER.get().useDeArrow
+ || ALT_THUMBNAIL_SEARCH.get().useDeArrow;
+ }
+
+ @Override
+ public boolean isAvailable() {
+ return usingDeArrowAnywhere();
+ }
+ }
+
+ public static final class StillImagesAvailability implements Setting.Availability {
+ public static boolean usingStillImagesAnywhere() {
+ return ALT_THUMBNAIL_HOME.get().useStillImages
+ || ALT_THUMBNAIL_SUBSCRIPTIONS.get().useStillImages
+ || ALT_THUMBNAIL_LIBRARY.get().useStillImages
+ || ALT_THUMBNAIL_PLAYER.get().useStillImages
+ || ALT_THUMBNAIL_SEARCH.get().useStillImages;
+ }
+
+ @Override
+ public boolean isAvailable() {
+ return usingStillImagesAnywhere();
+ }
+ }
+
+ public enum ThumbnailOption {
+ ORIGINAL(false, false),
+ DEARROW(true, false),
+ DEARROW_STILL_IMAGES(true, true),
+ STILL_IMAGES(false, true);
+
+ final boolean useDeArrow;
+ final boolean useStillImages;
+
+ ThumbnailOption(boolean useDeArrow, boolean useStillImages) {
+ this.useDeArrow = useDeArrow;
+ this.useStillImages = useStillImages;
+ }
+ }
+
+ public enum ThumbnailStillTime {
+ BEGINNING(1),
+ MIDDLE(2),
+ END(3);
+
+ /**
+ * The url alt image number. Such as the 2 in 'hq720_2.jpg'
+ */
+ final int altImageNumber;
+
+ ThumbnailStillTime(int altImageNumber) {
+ this.altImageNumber = altImageNumber;
+ }
+ }
+
+ private static final Uri dearrowApiUri;
+
+ /**
+ * The scheme and host of {@link #dearrowApiUri}.
+ */
+ private static final String deArrowApiUrlPrefix;
+
+ /**
+ * How long to temporarily turn off DeArrow if it fails for any reason.
+ */
+ private static final long DEARROW_FAILURE_API_BACKOFF_MILLISECONDS = 5 * 60 * 1000; // 5 Minutes.
+
+ /**
+ * Regex to match youtube static thumbnails domain.
+ * Used to find and replace blocked domain with a working ones
+ */
+ private static final String YOUTUBE_STATIC_THUMBNAILS_DOMAIN_REGEX = "(yt[3-4]|lh[3-6]|play-lh)\\.(ggpht|googleusercontent)\\.com";
+
+ private static final Pattern YOUTUBE_STATIC_THUMBNAILS_DOMAIN_PATTERN = Pattern.compile(YOUTUBE_STATIC_THUMBNAILS_DOMAIN_REGEX);
+
+ /**
+ * If non zero, then the system time of when DeArrow API calls can resume.
+ */
+ private static volatile long timeToResumeDeArrowAPICalls;
+
+ static {
+ dearrowApiUri = validateSettings();
+ final int port = dearrowApiUri.getPort();
+ String portString = port == -1 ? "" : (":" + port);
+ deArrowApiUrlPrefix = dearrowApiUri.getScheme() + "://" + dearrowApiUri.getHost() + portString + "/";
+ Logger.printDebug(() -> "Using DeArrow API address: " + deArrowApiUrlPrefix);
+ }
+
+ /**
+ * Fix any bad imported data.
+ */
+ private static Uri validateSettings() {
+ Uri apiUri = Uri.parse(Settings.ALT_THUMBNAIL_DEARROW_API_URL.get());
+ // Cannot use unsecured 'http', otherwise the connections fail to start and no callbacks hooks are made.
+ String scheme = apiUri.getScheme();
+ if (scheme == null || scheme.equals("http") || apiUri.getHost() == null) {
+ Utils.showToastLong(str("revanced_alt_thumbnail_dearrow_api_url_invalid_toast"));
+ Utils.showToastShort(str("revanced_extended_reset_to_default_toast"));
+ Settings.ALT_THUMBNAIL_DEARROW_API_URL.resetToDefault();
+ return validateSettings();
+ }
+ return apiUri;
+ }
+
+ private static ThumbnailOption optionSettingForCurrentNavigation() {
+ // Must check player type first, as search bar can be active behind the player.
+ if (RootView.isPlayerActive()) {
+ return ALT_THUMBNAIL_PLAYER.get();
+ }
+
+ // Must check second, as search can be from any tab.
+ if (RootView.isSearchBarActive()) {
+ return ALT_THUMBNAIL_SEARCH.get();
+ }
+
+ // Avoid checking which navigation button is selected, if all other settings are the same.
+ ThumbnailOption homeOption = ALT_THUMBNAIL_HOME.get();
+ ThumbnailOption subscriptionsOption = ALT_THUMBNAIL_SUBSCRIPTIONS.get();
+ ThumbnailOption libraryOption = ALT_THUMBNAIL_LIBRARY.get();
+ if ((homeOption == subscriptionsOption) && (homeOption == libraryOption)) {
+ return homeOption; // All are the same option.
+ }
+
+ NavigationButton selectedNavButton = NavigationButton.getSelectedNavigationButton();
+ if (selectedNavButton == null) {
+ // Unknown tab, treat as the home tab;
+ return homeOption;
+ }
+ if (selectedNavButton == NavigationButton.HOME) {
+ return homeOption;
+ }
+ if (selectedNavButton == NavigationButton.SUBSCRIPTIONS || selectedNavButton == NavigationButton.NOTIFICATIONS) {
+ return subscriptionsOption;
+ }
+ // A library tab variant is active.
+ return libraryOption;
+ }
+
+ /**
+ * Build the alternative thumbnail url using YouTube provided still video captures.
+ *
+ * @param decodedUrl Decoded original thumbnail request url.
+ * @return The alternative thumbnail url, or the original url. Both without tracking parameters.
+ */
+ @NonNull
+ private static String buildYoutubeVideoStillURL(@NonNull DecodedThumbnailUrl decodedUrl,
+ @NonNull ThumbnailQuality qualityToUse) {
+ String sanitizedReplacement = decodedUrl.createStillsUrl(qualityToUse, false);
+ if (VerifiedQualities.verifyAltThumbnailExist(decodedUrl.videoId, qualityToUse, sanitizedReplacement)) {
+ return sanitizedReplacement;
+ }
+ return decodedUrl.sanitizedUrl;
+ }
+
+ /**
+ * Build the alternative thumbnail url using DeArrow thumbnail cache.
+ *
+ * @param videoId ID of the video to get a thumbnail of. Can be any video (regular or Short).
+ * @param fallbackUrl URL to fall back to in case.
+ * @return The alternative thumbnail url, without tracking parameters.
+ */
+ @NonNull
+ private static String buildDeArrowThumbnailURL(String videoId, String fallbackUrl) {
+ // Build thumbnail request url.
+ // See https://github.com/ajayyy/DeArrowThumbnailCache/blob/29eb4359ebdf823626c79d944a901492d760bbbc/app.py#L29.
+ return dearrowApiUri
+ .buildUpon()
+ .appendQueryParameter("videoID", videoId)
+ .appendQueryParameter("redirectUrl", fallbackUrl)
+ .build()
+ .toString();
+ }
+
+ private static boolean urlIsDeArrow(@NonNull String imageUrl) {
+ return imageUrl.startsWith(deArrowApiUrlPrefix);
+ }
+
+ /**
+ * @return If this client has not recently experienced any DeArrow API errors.
+ */
+ private static boolean canUseDeArrowAPI() {
+ if (timeToResumeDeArrowAPICalls == 0) {
+ return true;
+ }
+ if (timeToResumeDeArrowAPICalls < System.currentTimeMillis()) {
+ Logger.printDebug(() -> "Resuming DeArrow API calls");
+ timeToResumeDeArrowAPICalls = 0;
+ return true;
+ }
+ return false;
+ }
+
+ private static void handleDeArrowError(@NonNull String url, int statusCode) {
+ Logger.printDebug(() -> "Encountered DeArrow error. Url: " + url);
+ final long now = System.currentTimeMillis();
+ if (timeToResumeDeArrowAPICalls < now) {
+ timeToResumeDeArrowAPICalls = now + DEARROW_FAILURE_API_BACKOFF_MILLISECONDS;
+ if (Settings.ALT_THUMBNAIL_DEARROW_CONNECTION_TOAST.get()) {
+ String toastMessage = (statusCode != 0)
+ ? str("revanced_alt_thumbnail_dearrow_error", statusCode)
+ : str("revanced_alt_thumbnail_dearrow_error_generic");
+ Utils.showToastLong(toastMessage);
+ }
+ }
+ }
+
+ /**
+ * Injection point. Called off the main thread and by multiple threads at the same time.
+ *
+ * @param originalUrl Image url for all url images loaded, including video thumbnails.
+ */
+ public static String overrideImageURL(String originalUrl) {
+ try {
+ ThumbnailOption option = optionSettingForCurrentNavigation();
+
+ if (option == ThumbnailOption.ORIGINAL) {
+ return originalUrl;
+ }
+
+ final var decodedUrl = DecodedThumbnailUrl.decodeImageUrl(originalUrl);
+ if (decodedUrl == null) {
+ return originalUrl; // Not a thumbnail.
+ }
+
+ Logger.printDebug(() -> "Original url: " + decodedUrl.sanitizedUrl);
+
+ ThumbnailQuality qualityToUse = ThumbnailQuality.getQualityToUse(decodedUrl.imageQuality);
+ if (qualityToUse == null) {
+ // Thumbnail is a Short or a Storyboard image used for seekbar thumbnails (must not replace these).
+ return originalUrl;
+ }
+
+ String sanitizedReplacementUrl;
+ final boolean includeTracking;
+ if (option.useDeArrow && canUseDeArrowAPI()) {
+ includeTracking = false; // Do not include view tracking parameters with API call.
+ final String fallbackUrl = option.useStillImages
+ ? buildYoutubeVideoStillURL(decodedUrl, qualityToUse)
+ : decodedUrl.sanitizedUrl;
+
+ sanitizedReplacementUrl = buildDeArrowThumbnailURL(decodedUrl.videoId, fallbackUrl);
+ } else if (option.useStillImages) {
+ includeTracking = true; // Include view tracking parameters if present.
+ sanitizedReplacementUrl = buildYoutubeVideoStillURL(decodedUrl, qualityToUse);
+ } else {
+ return originalUrl; // Recently experienced DeArrow failure and video stills are not enabled.
+ }
+
+ // Do not log any tracking parameters.
+ Logger.printDebug(() -> "Replacement url: " + sanitizedReplacementUrl);
+
+ return includeTracking
+ ? sanitizedReplacementUrl + decodedUrl.viewTrackingParameters
+ : sanitizedReplacementUrl;
+ } catch (Exception ex) {
+ Logger.printException(() -> "overrideImageURL failure", ex);
+ return originalUrl;
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * Cronet considers all completed connections as a success, even if the response is 404 or 5xx.
+ */
+ public static void handleCronetSuccess(UrlRequest request, @NonNull UrlResponseInfo responseInfo) {
+ try {
+ final int statusCode = responseInfo.getHttpStatusCode();
+ if (statusCode == 200) {
+ return;
+ }
+
+ String url = responseInfo.getUrl();
+
+ if (urlIsDeArrow(url)) {
+ Logger.printDebug(() -> "handleCronetSuccess, statusCode: " + statusCode);
+ if (statusCode == 304) {
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304
+ return; // Normal response.
+ }
+ handleDeArrowError(url, statusCode);
+ return;
+ }
+
+ if (statusCode == 404) {
+ // Fast alt thumbnails is enabled and the thumbnail is not available.
+ // The video is:
+ // - live stream
+ // - upcoming unreleased video
+ // - very old
+ // - very low view count
+ // Take note of this, so if the image reloads the original thumbnail will be used.
+ DecodedThumbnailUrl decodedUrl = DecodedThumbnailUrl.decodeImageUrl(url);
+ if (decodedUrl == null) {
+ return; // Not a thumbnail.
+ }
+
+ Logger.printDebug(() -> "handleCronetSuccess, image not available: " + url);
+
+ ThumbnailQuality quality = ThumbnailQuality.altImageNameToQuality(decodedUrl.imageQuality);
+ if (quality == null) {
+ // Video is a short or a seekbar thumbnail, but somehow did not load. Should not happen.
+ Logger.printDebug(() -> "Failed to recognize image quality of url: " + decodedUrl.sanitizedUrl);
+ return;
+ }
+
+ VerifiedQualities.setAltThumbnailDoesNotExist(decodedUrl.videoId, quality);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "Callback success error", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * To test failure cases, try changing the API URL to each of:
+ * - A non-existent domain.
+ * - A url path of something incorrect (ie: /v1/nonExistentEndPoint).
+ *
+ * Cronet uses a very timeout (several minutes), so if the API never responds this hook can take a while to be called.
+ * But this does not appear to be a problem, as the DeArrow API has not been observed to 'go silent'
+ * Instead if there's a problem it returns an error code status response, which is handled in this patch.
+ */
+ public static void handleCronetFailure(UrlRequest request,
+ @Nullable UrlResponseInfo responseInfo,
+ IOException exception) {
+ try {
+ String url = ((CronetUrlRequest) request).getHookedUrl();
+ if (urlIsDeArrow(url)) {
+ Logger.printDebug(() -> "handleCronetFailure, exception: " + exception);
+ final int statusCode = (responseInfo != null)
+ ? responseInfo.getHttpStatusCode()
+ : 0;
+ handleDeArrowError(url, statusCode);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "Callback failure error", ex);
+ }
+ }
+
+ private enum ThumbnailQuality {
+ // In order of lowest to highest resolution.
+ DEFAULT("default", ""), // effective alt name is 1.jpg, 2.jpg, 3.jpg
+ MQDEFAULT("mqdefault", "mq"),
+ HQDEFAULT("hqdefault", "hq"),
+ SDDEFAULT("sddefault", "sd"),
+ HQ720("hq720", "hq720_"),
+ MAXRESDEFAULT("maxresdefault", "maxres");
+
+ /**
+ * Lookup map of original name to enum.
+ */
+ private static final Map originalNameToEnum = new HashMap<>();
+
+ /**
+ * Lookup map of alt name to enum. ie: "hq720_1" to {@link #HQ720}.
+ */
+ private static final Map altNameToEnum = new HashMap<>();
+
+ static {
+ for (ThumbnailQuality quality : values()) {
+ originalNameToEnum.put(quality.originalName, quality);
+
+ for (ThumbnailStillTime time : ThumbnailStillTime.values()) {
+ // 'custom' thumbnails set by the content creator.
+ // These show up in place of regular thumbnails
+ // and seem to be limited to the same [1, 3] range as the still captures.
+ originalNameToEnum.put(quality.originalName + "_custom_" + time.altImageNumber, quality);
+
+ altNameToEnum.put(quality.altImageName + time.altImageNumber, quality);
+ }
+ }
+ }
+
+ /**
+ * Convert an alt image name to enum.
+ * ie: "hq720_2" returns {@link #HQ720}.
+ */
+ @Nullable
+ static ThumbnailQuality altImageNameToQuality(@NonNull String altImageName) {
+ return altNameToEnum.get(altImageName);
+ }
+
+ /**
+ * Original quality to effective alt quality to use.
+ * ie: If fast alt image is enabled, then "hq720" returns {@link #SDDEFAULT}.
+ */
+ @Nullable
+ static ThumbnailQuality getQualityToUse(@NonNull String originalSize) {
+ ThumbnailQuality quality = originalNameToEnum.get(originalSize);
+ if (quality == null) {
+ return null; // Not a thumbnail for a regular video.
+ }
+
+ final boolean useFastQuality = Settings.ALT_THUMBNAIL_STILLS_FAST.get();
+ switch (quality) {
+ case SDDEFAULT:
+ // SD alt images have somewhat worse quality with washed out color and poor contrast.
+ // But the 720 images look much better and don't suffer from these issues.
+ // For unknown reasons, the 720 thumbnails are used only for the home feed,
+ // while SD is used for the search and subscription feed
+ // (even though search and subscriptions use the exact same layout as the home feed).
+ // Of note, this image quality issue only appears with the alt thumbnail images,
+ // and the regular thumbnails have identical color/contrast quality for all sizes.
+ // Fix this by falling thru and upgrading SD to 720.
+ case HQ720:
+ if (useFastQuality) {
+ return SDDEFAULT; // SD is max resolution for fast alt images.
+ }
+ return HQ720;
+ case MAXRESDEFAULT:
+ if (useFastQuality) {
+ return SDDEFAULT;
+ }
+ return MAXRESDEFAULT;
+ default:
+ return quality;
+ }
+ }
+
+ final String originalName;
+ final String altImageName;
+
+ ThumbnailQuality(String originalName, String altImageName) {
+ this.originalName = originalName;
+ this.altImageName = altImageName;
+ }
+
+ String getAltImageNameToUse() {
+ return altImageName + Settings.ALT_THUMBNAIL_STILLS_TIME.get().altImageNumber;
+ }
+ }
+
+ /**
+ * Uses HTTP HEAD requests to verify and keep track of which thumbnail sizes
+ * are available and not available.
+ */
+ private static class VerifiedQualities {
+ /**
+ * After a quality is verified as not available, how long until the quality is re-verified again.
+ * Used only if fast mode is not enabled. Intended for live streams and unreleased videos
+ * that are now finished and available (and thus, the alt thumbnails are also now available).
+ */
+ private static final long NOT_AVAILABLE_TIMEOUT_MILLISECONDS = 10 * 60 * 1000; // 10 minutes.
+
+ /**
+ * Cache used to verify if an alternative thumbnails exists for a given video id.
+ */
+ @GuardedBy("itself")
+ private static final Map altVideoIdLookup = new LinkedHashMap<>(100) {
+ private static final int CACHE_LIMIT = 1000;
+
+ @Override
+ protected boolean removeEldestEntry(Entry eldest) {
+ return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit.
+ }
+ };
+
+ private static VerifiedQualities getVerifiedQualities(@NonNull String videoId, boolean returnNullIfDoesNotExist) {
+ synchronized (altVideoIdLookup) {
+ VerifiedQualities verified = altVideoIdLookup.get(videoId);
+ if (verified == null) {
+ if (returnNullIfDoesNotExist) {
+ return null;
+ }
+ verified = new VerifiedQualities();
+ altVideoIdLookup.put(videoId, verified);
+ }
+ return verified;
+ }
+ }
+
+ static boolean verifyAltThumbnailExist(@NonNull String videoId, @NonNull ThumbnailQuality quality,
+ @NonNull String imageUrl) {
+ VerifiedQualities verified = getVerifiedQualities(videoId, Settings.ALT_THUMBNAIL_STILLS_FAST.get());
+ if (verified == null) return true; // Fast alt thumbnails is enabled.
+ return verified.verifyYouTubeThumbnailExists(videoId, quality, imageUrl);
+ }
+
+ static void setAltThumbnailDoesNotExist(@NonNull String videoId, @NonNull ThumbnailQuality quality) {
+ VerifiedQualities verified = getVerifiedQualities(videoId, false);
+ //noinspection ConstantConditions
+ verified.setQualityVerified(videoId, quality, false);
+ }
+
+ /**
+ * Highest quality verified as existing.
+ */
+ @Nullable
+ private ThumbnailQuality highestQualityVerified;
+ /**
+ * Lowest quality verified as not existing.
+ */
+ @Nullable
+ private ThumbnailQuality lowestQualityNotAvailable;
+
+ /**
+ * System time, of when to invalidate {@link #lowestQualityNotAvailable}.
+ * Used only if fast mode is not enabled.
+ */
+ private long timeToReVerifyLowestQuality;
+
+ private synchronized void setQualityVerified(String videoId, ThumbnailQuality quality, boolean isVerified) {
+ if (isVerified) {
+ if (highestQualityVerified == null || highestQualityVerified.ordinal() < quality.ordinal()) {
+ highestQualityVerified = quality;
+ }
+ } else {
+ if (lowestQualityNotAvailable == null || lowestQualityNotAvailable.ordinal() > quality.ordinal()) {
+ lowestQualityNotAvailable = quality;
+ timeToReVerifyLowestQuality = System.currentTimeMillis() + NOT_AVAILABLE_TIMEOUT_MILLISECONDS;
+ }
+ Logger.printDebug(() -> quality + " not available for video: " + videoId);
+ }
+ }
+
+ /**
+ * Verify if a video alt thumbnail exists. Does so by making a minimal HEAD http request.
+ */
+ synchronized boolean verifyYouTubeThumbnailExists(@NonNull String videoId, @NonNull ThumbnailQuality quality,
+ @NonNull String imageUrl) {
+ if (highestQualityVerified != null && highestQualityVerified.ordinal() >= quality.ordinal()) {
+ return true; // Previously verified as existing.
+ }
+
+ final boolean fastQuality = Settings.ALT_THUMBNAIL_STILLS_FAST.get();
+ if (lowestQualityNotAvailable != null && lowestQualityNotAvailable.ordinal() <= quality.ordinal()) {
+ if (fastQuality || System.currentTimeMillis() < timeToReVerifyLowestQuality) {
+ return false; // Previously verified as not existing.
+ }
+ // Enough time has passed, and should re-verify again.
+ Logger.printDebug(() -> "Resetting lowest verified quality for: " + videoId);
+ lowestQualityNotAvailable = null;
+ }
+
+ if (fastQuality) {
+ return true; // Unknown if it exists or not. Use the URL anyways and update afterwards if loading fails.
+ }
+
+ boolean imageFileFound;
+ try {
+ // This hooked code is running on a low priority thread, and it's slightly faster
+ // to run the url connection thru the integrations thread pool which runs at the highest priority.
+ final long start = System.currentTimeMillis();
+ imageFileFound = Utils.submitOnBackgroundThread(() -> {
+ final int connectionTimeoutMillis = 10000; // 10 seconds.
+ HttpURLConnection connection = (HttpURLConnection) new URL(imageUrl).openConnection();
+ connection.setConnectTimeout(connectionTimeoutMillis);
+ connection.setReadTimeout(connectionTimeoutMillis);
+ connection.setRequestMethod("HEAD");
+ // Even with a HEAD request, the response is the same size as a full GET request.
+ // Using an empty range fixes this.
+ connection.setRequestProperty("Range", "bytes=0-0");
+ final int responseCode = connection.getResponseCode();
+ if (responseCode == HttpURLConnection.HTTP_PARTIAL) {
+ String contentType = connection.getContentType();
+ return (contentType != null && contentType.startsWith("image"));
+ }
+ if (responseCode != HttpURLConnection.HTTP_NOT_FOUND) {
+ Logger.printDebug(() -> "Unexpected response code: " + responseCode + " for url: " + imageUrl);
+ }
+ return false;
+ }).get();
+ Logger.printDebug(() -> "Verification took: " + (System.currentTimeMillis() - start) + "ms for image: " + imageUrl);
+ } catch (ExecutionException | InterruptedException ex) {
+ Logger.printInfo(() -> "Could not verify alt url: " + imageUrl, ex);
+ imageFileFound = false;
+ }
+
+ setQualityVerified(videoId, quality, imageFileFound);
+ return imageFileFound;
+ }
+ }
+
+ /**
+ * YouTube video thumbnail url, decoded into it's relevant parts.
+ */
+ private static class DecodedThumbnailUrl {
+ /**
+ * YouTube thumbnail URL prefix. Can be '/vi/' or '/vi_webp/'
+ */
+ private static final String YOUTUBE_THUMBNAIL_PREFIX = "https://i.ytimg.com/vi";
+
+ @Nullable
+ static DecodedThumbnailUrl decodeImageUrl(String url) {
+ final int videoIdStartIndex = url.indexOf('/', YOUTUBE_THUMBNAIL_PREFIX.length()) + 1;
+ if (videoIdStartIndex <= 0) return null;
+
+ final int videoIdEndIndex = url.indexOf('/', videoIdStartIndex);
+ if (videoIdEndIndex < 0) return null;
+
+ final int imageSizeStartIndex = videoIdEndIndex + 1;
+ final int imageSizeEndIndex = url.indexOf('.', imageSizeStartIndex);
+ if (imageSizeEndIndex < 0) return null;
+
+ int imageExtensionEndIndex = url.indexOf('?', imageSizeEndIndex);
+ if (imageExtensionEndIndex < 0) imageExtensionEndIndex = url.length();
+
+ return new DecodedThumbnailUrl(url, videoIdStartIndex, videoIdEndIndex,
+ imageSizeStartIndex, imageSizeEndIndex, imageExtensionEndIndex);
+ }
+
+ final String originalFullUrl;
+ /**
+ * Full usable url, but stripped of any tracking information.
+ */
+ final String sanitizedUrl;
+ /**
+ * Url up to the video ID.
+ */
+ final String urlPrefix;
+ final String videoId;
+ /**
+ * Quality, such as hq720 or sddefault.
+ */
+ final String imageQuality;
+ /**
+ * JPG or WEBP
+ */
+ final String imageExtension;
+ /**
+ * User view tracking parameters, only present on some images.
+ */
+ final String viewTrackingParameters;
+
+ DecodedThumbnailUrl(String fullUrl, int videoIdStartIndex, int videoIdEndIndex,
+ int imageSizeStartIndex, int imageSizeEndIndex, int imageExtensionEndIndex) {
+ originalFullUrl = fullUrl;
+ sanitizedUrl = fullUrl.substring(0, imageExtensionEndIndex);
+ urlPrefix = fullUrl.substring(0, videoIdStartIndex);
+ videoId = fullUrl.substring(videoIdStartIndex, videoIdEndIndex);
+ imageQuality = fullUrl.substring(imageSizeStartIndex, imageSizeEndIndex);
+ imageExtension = fullUrl.substring(imageSizeEndIndex + 1, imageExtensionEndIndex);
+ viewTrackingParameters = (imageExtensionEndIndex == fullUrl.length())
+ ? "" : fullUrl.substring(imageExtensionEndIndex);
+ }
+
+ /**
+ * @noinspection SameParameterValue
+ */
+ String createStillsUrl(@NonNull ThumbnailQuality qualityToUse, boolean includeViewTracking) {
+ // Images could be upgraded to webp if they are not already, but this fails quite often,
+ // especially for new videos uploaded in the last hour.
+ // And even if alt webp images do exist, sometimes they can load much slower than the original jpg alt images.
+ // (as much as 4x slower has been observed, despite the alt webp image being a smaller file).
+ StringBuilder builder = new StringBuilder(originalFullUrl.length() + 2);
+ builder.append(urlPrefix);
+ builder.append(videoId).append('/');
+ builder.append(qualityToUse.getAltImageNameToUse());
+ builder.append('.').append(imageExtension);
+ if (includeViewTracking) {
+ builder.append(viewTrackingParameters);
+ }
+ return builder.toString();
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ActionButtonsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ActionButtonsFilter.java
new file mode 100644
index 0000000000..69386f21fb
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ActionButtonsFilter.java
@@ -0,0 +1,118 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+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.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class ActionButtonsFilter extends Filter {
+ private static final String VIDEO_ACTION_BAR_PATH_PREFIX = "video_action_bar.eml";
+ private static final String ANIMATED_VECTOR_TYPE_PATH = "AnimatedVectorType";
+
+ private final StringFilterGroup actionBarRule;
+ private final StringFilterGroup bufferFilterPathRule;
+ private final StringFilterGroup likeSubscribeGlow;
+ private final ByteArrayFilterGroupList bufferButtonsGroupList = new ByteArrayFilterGroupList();
+
+ public ActionButtonsFilter() {
+ actionBarRule = new StringFilterGroup(
+ null,
+ VIDEO_ACTION_BAR_PATH_PREFIX
+ );
+ addIdentifierCallbacks(actionBarRule);
+
+ bufferFilterPathRule = new StringFilterGroup(
+ null,
+ "|ContainerType|button.eml|"
+ );
+ likeSubscribeGlow = new StringFilterGroup(
+ Settings.DISABLE_LIKE_DISLIKE_GLOW,
+ "animated_button_border.eml"
+ );
+ addPathCallbacks(
+ new StringFilterGroup(
+ Settings.HIDE_LIKE_DISLIKE_BUTTON,
+ "|segmented_like_dislike_button"
+ ),
+ new StringFilterGroup(
+ Settings.HIDE_DOWNLOAD_BUTTON,
+ "|download_button.eml|"
+ ),
+ new StringFilterGroup(
+ Settings.HIDE_CLIP_BUTTON,
+ "|clip_button.eml|"
+ ),
+ new StringFilterGroup(
+ Settings.HIDE_PLAYLIST_BUTTON,
+ "|save_to_playlist_button"
+ ),
+ new StringFilterGroup(
+ Settings.HIDE_REWARDS_BUTTON,
+ "account_link_button"
+ ),
+ bufferFilterPathRule,
+ likeSubscribeGlow
+ );
+
+ bufferButtonsGroupList.addAll(
+ new ByteArrayFilterGroup(
+ Settings.HIDE_REPORT_BUTTON,
+ "yt_outline_flag"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHARE_BUTTON,
+ "yt_outline_share"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_REMIX_BUTTON,
+ "yt_outline_youtube_shorts_plus"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHOP_BUTTON,
+ "yt_outline_bag"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_THANKS_BUTTON,
+ "yt_outline_dollar_sign_heart"
+ )
+ );
+ }
+
+ private boolean isEveryFilterGroupEnabled() {
+ for (StringFilterGroup group : pathCallbacks)
+ if (!group.isEnabled()) return false;
+
+ for (ByteArrayFilterGroup group : bufferButtonsGroupList)
+ if (!group.isEnabled()) return false;
+
+ return true;
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (!path.startsWith(VIDEO_ACTION_BAR_PATH_PREFIX)) {
+ return false;
+ }
+ if (matchedGroup == actionBarRule && !isEveryFilterGroupEnabled()) {
+ return false;
+ }
+ if (matchedGroup == likeSubscribeGlow) {
+ if (!path.contains(ANIMATED_VECTOR_TYPE_PATH)) {
+ return false;
+ }
+ }
+ if (matchedGroup == bufferFilterPathRule) {
+ // In case the group list has no match, return false.
+ if (!bufferButtonsGroupList.check(protobufBufferArray).isFiltered()) {
+ return false;
+ }
+ }
+
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/AdsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/AdsFilter.java
new file mode 100644
index 0000000000..e19532662a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/AdsFilter.java
@@ -0,0 +1,160 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup;
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * If A/B testing is applied, ad components can only be filtered by identifier
+ *
+ * Before A/B testing:
+ * Identifier: video_display_button_group_layout.eml
+ * Path: video_display_button_group_layout.eml|ContainerType|....
+ * (Path always starts with an Identifier)
+ *
+ * After A/B testing:
+ * Identifier: video_display_button_group_layout.eml
+ * Path: video_lockup_with_attachment.eml|ContainerType|....
+ * (Path does not contain an Identifier)
+ */
+@SuppressWarnings("unused")
+public final class AdsFilter extends Filter {
+
+ private final StringFilterGroup playerShoppingShelf;
+ private final ByteArrayFilterGroup playerShoppingShelfBuffer;
+
+ public AdsFilter() {
+
+ // Identifiers.
+
+ final StringFilterGroup alertBannerPromo = new StringFilterGroup(
+ Settings.HIDE_PROMOTION_ALERT_BANNER,
+ "alert_banner_promo.eml"
+ );
+
+ // Keywords checked in 2024:
+ final StringFilterGroup generalAdsIdentifier = new StringFilterGroup(
+ Settings.HIDE_GENERAL_ADS,
+ // "brand_video_shelf.eml"
+ "brand_video",
+
+ // "carousel_footered_layout.eml"
+ "carousel_footered_layout",
+
+ // "composite_concurrent_carousel_layout"
+ "composite_concurrent_carousel_layout",
+
+ // "landscape_image_wide_button_layout.eml"
+ "landscape_image_wide_button_layout",
+
+ // "square_image_layout.eml"
+ "square_image_layout",
+
+ // "statement_banner.eml"
+ "statement_banner",
+
+ // "video_display_full_layout.eml"
+ "video_display_full_layout",
+
+ // "text_image_button_group_layout.eml"
+ // "video_display_button_group_layout.eml"
+ "_button_group_layout",
+
+ // "banner_text_icon_buttoned_layout.eml"
+ // "video_display_compact_buttoned_layout.eml"
+ // "video_display_full_buttoned_layout.eml"
+ "_buttoned_layout",
+
+ // "compact_landscape_image_layout.eml"
+ // "full_width_portrait_image_layout.eml"
+ // "full_width_square_image_layout.eml"
+ "_image_layout"
+ );
+
+ final StringFilterGroup merchandise = new StringFilterGroup(
+ Settings.HIDE_MERCHANDISE_SHELF,
+ "product_carousel",
+ "shopping_carousel"
+ );
+
+ final StringFilterGroup paidContent = new StringFilterGroup(
+ Settings.HIDE_PAID_PROMOTION_LABEL,
+ "paid_content_overlay"
+ );
+
+ final StringFilterGroup selfSponsor = new StringFilterGroup(
+ Settings.HIDE_SELF_SPONSOR_CARDS,
+ "cta_shelf_card"
+ );
+
+ final StringFilterGroup viewProducts = new StringFilterGroup(
+ Settings.HIDE_VIEW_PRODUCTS,
+ "product_item",
+ "products_in_video",
+ "shopping_overlay"
+ );
+
+ final StringFilterGroup webSearchPanel = new StringFilterGroup(
+ Settings.HIDE_WEB_SEARCH_RESULTS,
+ "web_link_panel",
+ "web_result_panel"
+ );
+
+ addIdentifierCallbacks(
+ alertBannerPromo,
+ generalAdsIdentifier,
+ merchandise,
+ paidContent,
+ selfSponsor,
+ viewProducts,
+ webSearchPanel
+ );
+
+ // Path.
+
+ final StringFilterGroup generalAdsPath = new StringFilterGroup(
+ Settings.HIDE_GENERAL_ADS,
+ "carousel_ad",
+ "carousel_headered_layout",
+ "hero_promo_image",
+ "legal_disclosure",
+ "lumiere_promo_carousel",
+ "primetime_promo",
+ "product_details",
+ "text_image_button_layout",
+ "video_display_carousel_button",
+ "watch_metadata_app_promo"
+ );
+
+ playerShoppingShelf = new StringFilterGroup(
+ null,
+ "horizontal_shelf.eml"
+ );
+
+ playerShoppingShelfBuffer = new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_STORE_SHELF,
+ "shopping_item_card_list.eml"
+ );
+
+ addPathCallbacks(
+ generalAdsPath,
+ playerShoppingShelf,
+ viewProducts
+ );
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (matchedGroup == playerShoppingShelf) {
+ if (contentIndex == 0 && playerShoppingShelfBuffer.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CarouselShelfFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CarouselShelfFilter.java
new file mode 100644
index 0000000000..0f31c1e058
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CarouselShelfFilter.java
@@ -0,0 +1,95 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+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.StringTrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
+import app.revanced.extension.youtube.shared.RootView;
+
+@SuppressWarnings("unused")
+public final class CarouselShelfFilter extends Filter {
+ private static final String BROWSE_ID_HOME = "FEwhat_to_watch";
+ private static final String BROWSE_ID_LIBRARY = "FElibrary";
+ private static final String BROWSE_ID_NOTIFICATION = "FEactivity";
+ private static final String BROWSE_ID_NOTIFICATION_INBOX = "FEnotifications_inbox";
+ private static final String BROWSE_ID_PLAYLIST = "VLPL";
+ private static final String BROWSE_ID_SUBSCRIPTION = "FEsubscriptions";
+
+ private static final Supplier> knownBrowseId = () -> Stream.of(
+ BROWSE_ID_HOME,
+ BROWSE_ID_NOTIFICATION,
+ BROWSE_ID_PLAYLIST,
+ BROWSE_ID_SUBSCRIPTION
+ );
+
+ private static final Supplier> whitelistBrowseId = () -> Stream.of(
+ BROWSE_ID_LIBRARY,
+ BROWSE_ID_NOTIFICATION_INBOX
+ );
+
+ private final StringTrieSearch exceptions = new StringTrieSearch();
+ public final StringFilterGroup horizontalShelf;
+
+ public CarouselShelfFilter() {
+ exceptions.addPattern("library_recent_shelf.eml");
+
+ final StringFilterGroup carouselShelf = new StringFilterGroup(
+ Settings.HIDE_CAROUSEL_SHELF,
+ "horizontal_shelf_inline.eml",
+ "horizontal_tile_shelf.eml",
+ "horizontal_video_shelf.eml"
+ );
+
+ horizontalShelf = new StringFilterGroup(
+ Settings.HIDE_CAROUSEL_SHELF,
+ "horizontal_shelf.eml"
+ );
+
+ addPathCallbacks(carouselShelf, horizontalShelf);
+ }
+
+ private static boolean hideShelves(boolean playerActive, boolean searchBarActive, NavigationButton selectedNavButton, String browseId) {
+ // Must check player type first, as search bar can be active behind the player.
+ if (playerActive) {
+ return false;
+ }
+ // Must check second, as search can be from any tab.
+ if (searchBarActive) {
+ return true;
+ }
+ // Unknown tab, treat the same as home.
+ if (selectedNavButton == null) {
+ return true;
+ }
+ return knownBrowseId.get().anyMatch(browseId::equals) || whitelistBrowseId.get().noneMatch(browseId::equals);
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (exceptions.matches(path)) {
+ return false;
+ }
+ final boolean playerActive = RootView.isPlayerActive();
+ final boolean searchBarActive = RootView.isSearchBarActive();
+ final NavigationButton navigationButton = NavigationButton.getSelectedNavigationButton();
+ final String navigation = navigationButton == null ? "null" : navigationButton.name();
+ final String browseId = RootView.getBrowseId();
+ final boolean hideShelves = matchedGroup != horizontalShelf || hideShelves(playerActive, searchBarActive, navigationButton, browseId);
+ if (contentIndex != 0) {
+ return false;
+ }
+ Logger.printDebug(() -> "hideShelves: " + hideShelves + "\nplayerActive: " + playerActive + "\nsearchBarActive: " + searchBarActive + "\nbrowseId: " + browseId + "\nnavigation: " + navigation);
+ if (!hideShelves) {
+ return false;
+ }
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CommentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CommentsFilter.java
new file mode 100644
index 0000000000..d4525cff61
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CommentsFilter.java
@@ -0,0 +1,133 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.shared.utils.StringTrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class CommentsFilter extends Filter {
+ private static final String COMMENT_COMPOSER_PATH = "comment_composer";
+ private static final String COMMENT_ENTRY_POINT_TEASER_PATH = "comments_entry_point_teaser";
+ private static final Pattern COMMENT_PREVIEW_TEXT_PATTERN = Pattern.compile("comments_entry_point_teaser.+ContainerType");
+ private static final String FEED_VIDEO_PATH = "video_lockup_with_attachment";
+ private static final String VIDEO_METADATA_CAROUSEL_PATH = "video_metadata_carousel.eml";
+
+ private final StringFilterGroup comments;
+ private final StringFilterGroup commentsPreviewDots;
+ private final StringFilterGroup createShorts;
+ private final StringFilterGroup previewCommentText;
+ private final StringFilterGroup thanks;
+ private final StringFilterGroup timeStampAndEmojiPicker;
+ private final StringTrieSearch exceptions = new StringTrieSearch();
+
+ public CommentsFilter() {
+ exceptions.addPatterns("macro_markers_list_item");
+
+ final StringFilterGroup channelGuidelines = new StringFilterGroup(
+ Settings.HIDE_CHANNEL_GUIDELINES,
+ "channel_guidelines_entry_banner",
+ "community_guidelines",
+ "sponsorships_comments_upsell"
+ );
+
+ comments = new StringFilterGroup(
+ null,
+ VIDEO_METADATA_CAROUSEL_PATH,
+ "comments_"
+ );
+
+ commentsPreviewDots = new StringFilterGroup(
+ Settings.HIDE_PREVIEW_COMMENT_OLD_METHOD,
+ "|ContainerType|ContainerType|ContainerType|"
+ );
+
+ createShorts = new StringFilterGroup(
+ Settings.HIDE_COMMENT_CREATE_SHORTS_BUTTON,
+ "composer_short_creation_button"
+ );
+
+ final StringFilterGroup membersBanner = new StringFilterGroup(
+ Settings.HIDE_COMMENTS_BY_MEMBERS,
+ "sponsorships_comments_header.eml",
+ "sponsorships_comments_footer.eml"
+ );
+
+ final StringFilterGroup previewComment = new StringFilterGroup(
+ Settings.HIDE_PREVIEW_COMMENT_OLD_METHOD,
+ "|carousel_item.",
+ "|carousel_listener",
+ COMMENT_ENTRY_POINT_TEASER_PATH,
+ "comments_entry_point_simplebox"
+ );
+
+ previewCommentText = new StringFilterGroup(
+ Settings.HIDE_PREVIEW_COMMENT_NEW_METHOD,
+ COMMENT_ENTRY_POINT_TEASER_PATH
+ );
+
+ thanks = new StringFilterGroup(
+ Settings.HIDE_COMMENT_THANKS_BUTTON,
+ "|super_thanks_button.eml"
+ );
+
+ timeStampAndEmojiPicker = new StringFilterGroup(
+ Settings.HIDE_COMMENT_TIMESTAMP_AND_EMOJI_BUTTONS,
+ "|CellType|ContainerType|ContainerType|ContainerType|ContainerType|ContainerType|"
+ );
+
+
+ addIdentifierCallbacks(channelGuidelines);
+
+ addPathCallbacks(
+ comments,
+ commentsPreviewDots,
+ createShorts,
+ membersBanner,
+ previewComment,
+ previewCommentText,
+ thanks,
+ timeStampAndEmojiPicker
+ );
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (exceptions.matches(path))
+ return false;
+
+ if (matchedGroup == createShorts || matchedGroup == thanks || matchedGroup == timeStampAndEmojiPicker) {
+ if (path.startsWith(COMMENT_COMPOSER_PATH)) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ } else if (matchedGroup == comments) {
+ if (path.startsWith(FEED_VIDEO_PATH)) {
+ if (Settings.HIDE_COMMENTS_SECTION_IN_HOME_FEED.get()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ } else if (Settings.HIDE_COMMENTS_SECTION.get()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ } else if (matchedGroup == commentsPreviewDots) {
+ if (path.startsWith(VIDEO_METADATA_CAROUSEL_PATH)) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ } else if (matchedGroup == previewCommentText) {
+ if (COMMENT_PREVIEW_TEXT_PATTERN.matcher(path).find()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CustomFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CustomFilter.java
new file mode 100644
index 0000000000..2c165c084e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/CustomFilter.java
@@ -0,0 +1,164 @@
+package app.revanced.extension.youtube.patches.components;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.shared.utils.ByteTrieSearch;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * Allows custom filtering using a path and optionally a proto buffer string.
+ */
+@SuppressWarnings("unused")
+public final class CustomFilter extends Filter {
+
+ private static void showInvalidSyntaxToast(@NonNull String expression) {
+ Utils.showToastLong(str("revanced_custom_filter_toast_invalid_syntax", expression));
+ }
+
+ private static class CustomFilterGroup extends StringFilterGroup {
+ /**
+ * Optional character for the path that indicates the custom filter path must match the start.
+ * Must be the first character of the expression.
+ */
+ public static final String SYNTAX_STARTS_WITH = "^";
+
+ /**
+ * Optional character that separates the path from a proto buffer string pattern.
+ */
+ public static final String SYNTAX_BUFFER_SYMBOL = "$";
+
+ /**
+ * @return the parsed objects
+ */
+ @NonNull
+ @SuppressWarnings("ConstantConditions")
+ static Collection parseCustomFilterGroups() {
+ String rawCustomFilterText = Settings.CUSTOM_FILTER_STRINGS.get();
+ if (rawCustomFilterText.isBlank()) {
+ return Collections.emptyList();
+ }
+
+ // Map key is the path including optional special characters (^ and/or $)
+ Map result = new HashMap<>();
+ Pattern pattern = Pattern.compile(
+ "(" // map key group
+ + "(\\Q" + SYNTAX_STARTS_WITH + "\\E?)" // optional starts with
+ + "([^\\Q" + SYNTAX_BUFFER_SYMBOL + "\\E]*)" // path
+ + "(\\Q" + SYNTAX_BUFFER_SYMBOL + "\\E?)" // optional buffer symbol
+ + ")" // end map key group
+ + "(.*)"); // optional buffer string
+
+ for (String expression : rawCustomFilterText.split("\n")) {
+ if (expression.isBlank()) continue;
+
+ Matcher matcher = pattern.matcher(expression);
+ if (!matcher.find()) {
+ showInvalidSyntaxToast(expression);
+ continue;
+ }
+
+ final String mapKey = matcher.group(1);
+ final boolean pathStartsWith = !matcher.group(2).isEmpty();
+ final String path = matcher.group(3);
+ final boolean hasBufferSymbol = !matcher.group(4).isEmpty();
+ final String bufferString = matcher.group(5);
+
+ if (path.isBlank() || (hasBufferSymbol && bufferString.isBlank())) {
+ showInvalidSyntaxToast(expression);
+ continue;
+ }
+
+ // Use one group object for all expressions with the same path.
+ // This ensures the buffer is searched exactly once
+ // when multiple paths are used with different buffer strings.
+ CustomFilterGroup group = result.get(mapKey);
+ if (group == null) {
+ group = new CustomFilterGroup(pathStartsWith, path);
+ result.put(mapKey, group);
+ }
+ if (hasBufferSymbol) {
+ group.addBufferString(bufferString);
+ }
+ }
+
+ return result.values();
+ }
+
+ final boolean startsWith;
+ ByteTrieSearch bufferSearch;
+
+ CustomFilterGroup(boolean startsWith, @NonNull String path) {
+ super(Settings.CUSTOM_FILTER, path);
+ this.startsWith = startsWith;
+ }
+
+ void addBufferString(@NonNull String bufferString) {
+ if (bufferSearch == null) {
+ bufferSearch = new ByteTrieSearch();
+ }
+ bufferSearch.addPattern(bufferString.getBytes());
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ StringBuilder builder = new StringBuilder();
+ builder.append("CustomFilterGroup{");
+ builder.append("path=");
+ if (startsWith) builder.append(SYNTAX_STARTS_WITH);
+ builder.append(filters[0]);
+
+ if (bufferSearch != null) {
+ String delimitingCharacter = "❙";
+ builder.append(", bufferStrings=");
+ builder.append(delimitingCharacter);
+ for (byte[] bufferString : bufferSearch.getPatterns()) {
+ builder.append(new String(bufferString));
+ builder.append(delimitingCharacter);
+ }
+ }
+ builder.append("}");
+ return builder.toString();
+ }
+ }
+
+ public CustomFilter() {
+ Collection groups = CustomFilterGroup.parseCustomFilterGroups();
+
+ if (!groups.isEmpty()) {
+ CustomFilterGroup[] groupsArray = groups.toArray(new CustomFilterGroup[0]);
+ Logger.printDebug(() -> "Using Custom filters: " + Arrays.toString(groupsArray));
+ addPathCallbacks(groupsArray);
+ }
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ // All callbacks are custom filter groups.
+ CustomFilterGroup custom = (CustomFilterGroup) matchedGroup;
+ if (custom.startsWith && contentIndex != 0) {
+ return false;
+ }
+ if (custom.bufferSearch != null && !custom.bufferSearch.matches(protobufBufferArray)) {
+ return false;
+ }
+
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/DescriptionsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/DescriptionsFilter.java
new file mode 100644
index 0000000000..fb2224d181
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/DescriptionsFilter.java
@@ -0,0 +1,111 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+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.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class DescriptionsFilter extends Filter {
+ private final ByteArrayFilterGroupList macroMarkerShelfGroupList = new ByteArrayFilterGroupList();
+
+ private final StringFilterGroup howThisWasMadeSection;
+ private final StringFilterGroup infoCardsSection;
+ private final StringFilterGroup macroMarkerShelf;
+ private final StringFilterGroup shoppingLinks;
+
+ public DescriptionsFilter() {
+ // game section, music section and places section now use the same identifier in the latest version.
+ final StringFilterGroup attributesSection = new StringFilterGroup(
+ Settings.HIDE_ATTRIBUTES_SECTION,
+ "gaming_section.eml",
+ "music_section.eml",
+ "place_section.eml",
+ "video_attributes_section.eml"
+ );
+
+ final StringFilterGroup podcastSection = new StringFilterGroup(
+ Settings.HIDE_PODCAST_SECTION,
+ "playlist_section.eml"
+ );
+
+ final StringFilterGroup transcriptSection = new StringFilterGroup(
+ Settings.HIDE_TRANSCRIPT_SECTION,
+ "transcript_section.eml"
+ );
+
+ final StringFilterGroup videoSummarySection = new StringFilterGroup(
+ Settings.HIDE_AI_GENERATED_VIDEO_SUMMARY_SECTION,
+ "cell_expandable_metadata.eml-js"
+ );
+
+ addIdentifierCallbacks(
+ attributesSection,
+ podcastSection,
+ transcriptSection,
+ videoSummarySection
+ );
+
+ howThisWasMadeSection = new StringFilterGroup(
+ Settings.HIDE_CONTENTS_SECTION,
+ "how_this_was_made_section.eml"
+ );
+
+ infoCardsSection = new StringFilterGroup(
+ Settings.HIDE_INFO_CARDS_SECTION,
+ "infocards_section.eml"
+ );
+
+ macroMarkerShelf = new StringFilterGroup(
+ null,
+ "macro_markers_carousel.eml"
+ );
+
+ shoppingLinks = new StringFilterGroup(
+ Settings.HIDE_SHOPPING_LINKS,
+ "expandable_list.",
+ "shopping_description_shelf"
+ );
+
+ addPathCallbacks(
+ howThisWasMadeSection,
+ infoCardsSection,
+ macroMarkerShelf,
+ shoppingLinks
+ );
+
+ macroMarkerShelfGroupList.addAll(
+ new ByteArrayFilterGroup(
+ Settings.HIDE_CHAPTERS_SECTION,
+ "chapters_horizontal_shelf"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_KEY_CONCEPTS_SECTION,
+ "learning_concept_macro_markers_carousel_shelf"
+ )
+ );
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ // Check for the index because of likelihood of false positives.
+ if (matchedGroup == howThisWasMadeSection || matchedGroup == infoCardsSection || matchedGroup == shoppingLinks) {
+ if (contentIndex != 0) {
+ return false;
+ }
+ } else if (matchedGroup == macroMarkerShelf) {
+ if (contentIndex != 0) {
+ return false;
+ }
+ if (!macroMarkerShelfGroupList.check(protobufBufferArray).isFiltered()) {
+ return false;
+ }
+ }
+
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedComponentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedComponentsFilter.java
new file mode 100644
index 0000000000..52c791c9e3
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedComponentsFilter.java
@@ -0,0 +1,268 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup;
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.shared.patches.components.StringFilterGroupList;
+import app.revanced.extension.shared.utils.StringTrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class FeedComponentsFilter extends Filter {
+ private static final String CONVERSATION_CONTEXT_FEED_IDENTIFIER =
+ "horizontalCollectionSwipeProtector=null";
+ private static final String CONVERSATION_CONTEXT_SUBSCRIPTIONS_IDENTIFIER =
+ "heightConstraint=null";
+ private static final String INLINE_EXPANSION_PATH = "inline_expansion";
+ private static final String FEED_VIDEO_PATH = "video_lockup_with_attachment";
+
+ private static final ByteArrayFilterGroup inlineExpansion =
+ new ByteArrayFilterGroup(
+ Settings.HIDE_EXPANDABLE_CHIP,
+ "inline_expansion"
+ );
+
+ private static final ByteArrayFilterGroup mixPlaylists =
+ new ByteArrayFilterGroup(
+ Settings.HIDE_MIX_PLAYLISTS,
+ "&list="
+ );
+ private static final ByteArrayFilterGroup mixPlaylistsBufferExceptions =
+ new ByteArrayFilterGroup(
+ null,
+ "cell_description_body",
+ "channel_profile"
+ );
+ private static final StringTrieSearch mixPlaylistsContextExceptions = new StringTrieSearch();
+
+ private final StringFilterGroup channelProfile;
+ private final StringFilterGroup communityPosts;
+ private final StringFilterGroup expandableChip;
+ private final ByteArrayFilterGroup visitStoreButton;
+ private final StringFilterGroup videoLockup;
+
+ private static final StringTrieSearch communityPostsFeedGroupSearch = new StringTrieSearch();
+ private final StringFilterGroupList communityPostsFeedGroup = new StringFilterGroupList();
+
+
+ public FeedComponentsFilter() {
+ communityPostsFeedGroupSearch.addPatterns(
+ CONVERSATION_CONTEXT_FEED_IDENTIFIER,
+ CONVERSATION_CONTEXT_SUBSCRIPTIONS_IDENTIFIER
+ );
+ mixPlaylistsContextExceptions.addPatterns(
+ "V.ED", // playlist browse id
+ "java.lang.ref.WeakReference"
+ );
+
+ // Identifiers.
+
+ final StringFilterGroup chipsShelf = new StringFilterGroup(
+ Settings.HIDE_CHIPS_SHELF,
+ "chips_shelf"
+ );
+
+ communityPosts = new StringFilterGroup(
+ null,
+ "post_base_wrapper",
+ "images_post_root",
+ "images_post_slim",
+ "text_post_root"
+ );
+
+ final StringFilterGroup expandableShelf = new StringFilterGroup(
+ Settings.HIDE_EXPANDABLE_SHELF,
+ "expandable_section"
+ );
+
+ final StringFilterGroup feedSearchBar = new StringFilterGroup(
+ Settings.HIDE_FEED_SEARCH_BAR,
+ "search_bar_entry_point"
+ );
+
+ final StringFilterGroup tasteBuilder = new StringFilterGroup(
+ Settings.HIDE_FEED_SURVEY,
+ "selectable_item.eml",
+ "cell_button.eml"
+ );
+
+ videoLockup = new StringFilterGroup(
+ null,
+ FEED_VIDEO_PATH
+ );
+
+ addIdentifierCallbacks(
+ chipsShelf,
+ communityPosts,
+ expandableShelf,
+ feedSearchBar,
+ tasteBuilder,
+ videoLockup
+ );
+
+ // Paths.
+
+ final StringFilterGroup albumCard = new StringFilterGroup(
+ Settings.HIDE_ALBUM_CARDS,
+ "browsy_bar",
+ "official_card"
+ );
+
+ channelProfile = new StringFilterGroup(
+ Settings.HIDE_BROWSE_STORE_BUTTON,
+ "channel_profile.eml",
+ "page_header.eml" // new layout
+ );
+
+ visitStoreButton = new ByteArrayFilterGroup(
+ null,
+ "header_store_button"
+ );
+
+ final StringFilterGroup channelMemberShelf = new StringFilterGroup(
+ Settings.HIDE_CHANNEL_MEMBER_SHELF,
+ "member_recognition_shelf"
+ );
+
+ final StringFilterGroup channelProfileLinks = new StringFilterGroup(
+ Settings.HIDE_CHANNEL_PROFILE_LINKS,
+ "channel_header_links",
+ "attribution.eml" // new layout
+ );
+
+ expandableChip = new StringFilterGroup(
+ Settings.HIDE_EXPANDABLE_CHIP,
+ INLINE_EXPANSION_PATH,
+ "inline_expander",
+ "expandable_metadata.eml"
+ );
+
+ final StringFilterGroup feedSurvey = new StringFilterGroup(
+ Settings.HIDE_FEED_SURVEY,
+ "feed_nudge",
+ "_survey"
+ );
+
+ final StringFilterGroup forYouShelf = new StringFilterGroup(
+ Settings.HIDE_FOR_YOU_SHELF,
+ "mixed_content_shelf"
+ );
+
+ final StringFilterGroup imageShelf = new StringFilterGroup(
+ Settings.HIDE_IMAGE_SHELF,
+ "image_shelf"
+ );
+
+ final StringFilterGroup latestPosts = new StringFilterGroup(
+ Settings.HIDE_LATEST_POSTS,
+ "post_shelf"
+ );
+
+ final StringFilterGroup movieShelf = new StringFilterGroup(
+ Settings.HIDE_MOVIE_SHELF,
+ "compact_movie",
+ "horizontal_movie_shelf",
+ "movie_and_show_upsell_card",
+ "compact_tvfilm_item",
+ "offer_module"
+ );
+
+ final StringFilterGroup notifyMe = new StringFilterGroup(
+ Settings.HIDE_NOTIFY_ME_BUTTON,
+ "set_reminder_button"
+ );
+
+ final StringFilterGroup playables = new StringFilterGroup(
+ Settings.HIDE_PLAYABLES,
+ "horizontal_gaming_shelf.eml",
+ "mini_game_card.eml"
+ );
+
+ final StringFilterGroup subscriptionsChannelBar = new StringFilterGroup(
+ Settings.HIDE_SUBSCRIPTIONS_CAROUSEL,
+ "subscriptions_channel_bar"
+ );
+
+ final StringFilterGroup ticketShelf = new StringFilterGroup(
+ Settings.HIDE_TICKET_SHELF,
+ "ticket_horizontal_shelf",
+ "ticket_shelf"
+ );
+
+ addPathCallbacks(
+ albumCard,
+ channelProfile,
+ channelMemberShelf,
+ channelProfileLinks,
+ expandableChip,
+ feedSurvey,
+ forYouShelf,
+ imageShelf,
+ latestPosts,
+ movieShelf,
+ notifyMe,
+ playables,
+ subscriptionsChannelBar,
+ ticketShelf,
+ videoLockup
+ );
+
+ final StringFilterGroup communityPostsHomeAndRelatedVideos =
+ new StringFilterGroup(
+ Settings.HIDE_COMMUNITY_POSTS_HOME_RELATED_VIDEOS,
+ CONVERSATION_CONTEXT_FEED_IDENTIFIER
+ );
+
+ final StringFilterGroup communityPostsSubscriptions =
+ new StringFilterGroup(
+ Settings.HIDE_COMMUNITY_POSTS_SUBSCRIPTIONS,
+ CONVERSATION_CONTEXT_SUBSCRIPTIONS_IDENTIFIER
+ );
+
+ communityPostsFeedGroup.addAll(communityPostsHomeAndRelatedVideos, communityPostsSubscriptions);
+ }
+
+ /**
+ * Injection point.
+ *
+ * Called from a different place then the other filters.
+ */
+ public static boolean filterMixPlaylists(final Object conversionContext, final byte[] bytes) {
+ return bytes != null
+ && mixPlaylists.check(bytes).isFiltered()
+ && !mixPlaylistsBufferExceptions.check(bytes).isFiltered()
+ && !mixPlaylistsContextExceptions.matches(conversionContext.toString());
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (matchedGroup == channelProfile) {
+ if (contentIndex == 0 && visitStoreButton.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ } else if (matchedGroup == communityPosts) {
+ if (!communityPostsFeedGroupSearch.matches(allValue) && Settings.HIDE_COMMUNITY_POSTS_CHANNEL.get()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ if (!communityPostsFeedGroup.check(allValue).isFiltered()) {
+ return false;
+ }
+ } else if (matchedGroup == expandableChip) {
+ if (path.startsWith(FEED_VIDEO_PATH)) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ } else if (matchedGroup == videoLockup) {
+ if (contentIndex == 0 && path.startsWith("CellType|") && inlineExpansion.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedVideoFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedVideoFilter.java
new file mode 100644
index 0000000000..6a3587cffb
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedVideoFilter.java
@@ -0,0 +1,99 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+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.patches.components.StringFilterGroupList;
+import app.revanced.extension.shared.utils.StringTrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.RootView;
+
+@SuppressWarnings("unused")
+public final class FeedVideoFilter extends Filter {
+ private static final String CONVERSATION_CONTEXT_FEED_IDENTIFIER =
+ "horizontalCollectionSwipeProtector=null";
+ private static final String ENDORSEMENT_FOOTER_PATH = "endorsement_header_footer";
+
+ private static final StringTrieSearch feedOnlyVideoPattern = new StringTrieSearch();
+ // In search results, vertical video with shorts labels mostly include videos with gray descriptions.
+ // Filters without check process.
+ private final StringFilterGroup inlineShorts;
+ // Used for home, related videos, subscriptions, and search results.
+ private final StringFilterGroup videoLockup = new StringFilterGroup(
+ null,
+ "video_lockup_with_attachment.eml"
+ );
+ private final ByteArrayFilterGroupList feedAndDrawerGroupList = new ByteArrayFilterGroupList();
+ private final ByteArrayFilterGroupList feedOnlyGroupList = new ByteArrayFilterGroupList();
+ private final StringFilterGroupList videoLockupFilterGroup = new StringFilterGroupList();
+ private static final ByteArrayFilterGroup relatedVideo =
+ new ByteArrayFilterGroup(
+ Settings.HIDE_RELATED_VIDEOS,
+ "relatedH"
+ );
+
+ public FeedVideoFilter() {
+ feedOnlyVideoPattern.addPattern(CONVERSATION_CONTEXT_FEED_IDENTIFIER);
+
+ inlineShorts = new StringFilterGroup(
+ Settings.HIDE_RECOMMENDED_VIDEO,
+ "inline_shorts.eml" // vertical video with shorts label
+ );
+
+ addIdentifierCallbacks(inlineShorts);
+
+ addPathCallbacks(videoLockup);
+
+ feedAndDrawerGroupList.addAll(
+ new ByteArrayFilterGroup(
+ Settings.HIDE_RECOMMENDED_VIDEO,
+ ENDORSEMENT_FOOTER_PATH, // videos with gray descriptions
+ "high-ptsZ" // videos for membership only
+ )
+ );
+
+ feedOnlyGroupList.addAll(
+ new ByteArrayFilterGroup(
+ Settings.HIDE_LOW_VIEWS_VIDEO,
+ "g-highZ" // videos with less than 1000 views
+ )
+ );
+
+ videoLockupFilterGroup.addAll(
+ new StringFilterGroup(
+ Settings.HIDE_RECOMMENDED_VIDEO,
+ ENDORSEMENT_FOOTER_PATH
+ )
+ );
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (matchedGroup == inlineShorts) {
+ if (RootView.isSearchBarActive()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ } else if (matchedGroup == videoLockup) {
+ if (relatedVideo.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ if (feedOnlyVideoPattern.matches(allValue)) {
+ if (feedOnlyGroupList.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ } else if (videoLockupFilterGroup.check(allValue).isFiltered()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ } else {
+ if (feedAndDrawerGroupList.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ }
+ }
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedVideoViewsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedVideoViewsFilter.java
new file mode 100644
index 0000000000..9b0779ecc5
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/FeedVideoViewsFilter.java
@@ -0,0 +1,180 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.NavigationBar;
+import app.revanced.extension.youtube.shared.RootView;
+
+@SuppressWarnings("all")
+public final class FeedVideoViewsFilter extends Filter {
+
+ private final StringFilterGroup feedVideoFilter = new StringFilterGroup(
+ null,
+ "video_lockup_with_attachment.eml"
+ );
+
+ public FeedVideoViewsFilter() {
+ addPathCallbacks(feedVideoFilter);
+ }
+
+ private boolean hideFeedVideoViewsSettingIsActive() {
+ final boolean hideHome = Settings.HIDE_VIDEO_BY_VIEW_COUNTS_HOME.get();
+ final boolean hideSearch = Settings.HIDE_VIDEO_BY_VIEW_COUNTS_SEARCH.get();
+ final boolean hideSubscriptions = Settings.HIDE_VIDEO_BY_VIEW_COUNTS_SUBSCRIPTIONS.get();
+
+ if (!hideHome && !hideSearch && !hideSubscriptions) {
+ return false;
+ } else if (hideHome && hideSearch && hideSubscriptions) {
+ return true;
+ }
+
+ // Must check player type first, as search bar can be active behind the player.
+ if (RootView.isPlayerActive()) {
+ // For now, consider the under video results the same as the home feed.
+ return hideHome;
+ }
+
+ // Must check second, as search can be from any tab.
+ if (RootView.isSearchBarActive()) {
+ return hideSearch;
+ }
+
+ NavigationBar.NavigationButton selectedNavButton = NavigationBar.NavigationButton.getSelectedNavigationButton();
+ if (selectedNavButton == null) {
+ return hideHome; // Unknown tab, treat the same as home.
+ } else if (selectedNavButton == NavigationBar.NavigationButton.HOME) {
+ return hideHome;
+ } else if (selectedNavButton == NavigationBar.NavigationButton.SUBSCRIPTIONS) {
+ return hideSubscriptions;
+ }
+ // User is in the Library or Notifications tab.
+ return false;
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (hideFeedVideoViewsSettingIsActive() &&
+ filterByViews(protobufBufferArray)) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ return false;
+ }
+
+ private final String ARROW = " -> ";
+ private final String VIEWS = "views";
+ private final String[] parts = Settings.HIDE_VIDEO_VIEW_COUNTS_MULTIPLIER.get().split("\\n");
+ private Pattern[] viewCountPatterns = null;
+
+ /**
+ * Hide videos based on views count
+ */
+ private synchronized boolean filterByViews(byte[] protobufBufferArray) {
+ final String protobufString = new String(protobufBufferArray);
+ final long lessThan = Settings.HIDE_VIDEO_VIEW_COUNTS_LESS_THAN.get();
+ final long greaterThan = Settings.HIDE_VIDEO_VIEW_COUNTS_GREATER_THAN.get();
+
+ if (viewCountPatterns == null) {
+ viewCountPatterns = getViewCountPatterns(parts);
+ }
+
+ for (Pattern pattern : viewCountPatterns) {
+ final Matcher matcher = pattern.matcher(protobufString);
+ if (matcher.find()) {
+ String numString = Objects.requireNonNull(matcher.group(1));
+ double num = parseNumber(numString);
+ String multiplierKey = matcher.group(2);
+ long multiplierValue = getMultiplierValue(parts, multiplierKey);
+ return num * multiplierValue < lessThan || num * multiplierValue > greaterThan;
+ }
+ }
+
+ return false;
+ }
+
+ private synchronized double parseNumber(String numString) {
+ /**
+ * Some languages have comma (,) as a decimal separator.
+ * In order to detect those numbers as doubles in Java
+ * we convert commas (,) to dots (.).
+ * Unless we find a language that has commas used in
+ * a different manner, it should work.
+ */
+ numString = numString.replace(",", ".");
+
+ /**
+ * Some languages have dot (.) as a kilo separator.
+ * So we check with regex if there is a number with 3+
+ * digits after dot (.), we replace it with nothing
+ * to make Java understand the number as a whole.
+ */
+ if (numString.matches("\\d+\\.\\d{3,}")) {
+ numString = numString.replace(".", "");
+ }
+
+ return Double.parseDouble(numString);
+ }
+
+ private synchronized Pattern[] getViewCountPatterns(String[] parts) {
+ StringBuilder prefixPatternBuilder = new StringBuilder("(\\d+(?:[.,]\\d+)?)\\s?("); // LTR layout
+ StringBuilder secondPatternBuilder = new StringBuilder(); // RTL layout
+ StringBuilder suffixBuilder = getSuffixBuilder(parts, prefixPatternBuilder, secondPatternBuilder);
+
+ prefixPatternBuilder.deleteCharAt(prefixPatternBuilder.length() - 1); // Remove the trailing |
+ prefixPatternBuilder.append(")?\\s*");
+ prefixPatternBuilder.append(suffixBuilder.length() > 0 ? suffixBuilder.toString() : VIEWS);
+
+ secondPatternBuilder.deleteCharAt(secondPatternBuilder.length() - 1); // Remove the trailing |
+ secondPatternBuilder.append(")?");
+
+ final Pattern[] patterns = new Pattern[2];
+ patterns[0] = Pattern.compile(prefixPatternBuilder.toString());
+ patterns[1] = Pattern.compile(secondPatternBuilder.toString());
+
+ return patterns;
+ }
+
+ @NonNull
+ private synchronized StringBuilder getSuffixBuilder(String[] parts, StringBuilder prefixPatternBuilder, StringBuilder secondPatternBuilder) {
+ StringBuilder suffixBuilder = new StringBuilder();
+
+ for (String part : parts) {
+ final String[] pair = part.split(ARROW);
+ final String pair0 = pair[0].trim();
+ final String pair1 = pair[1].trim();
+
+ if (pair.length == 2 && !pair1.equals(VIEWS)) {
+ prefixPatternBuilder.append(pair0).append("|");
+ }
+
+ if (pair.length == 2 && pair1.equals(VIEWS)) {
+ suffixBuilder.append(pair0);
+ secondPatternBuilder.append(pair0).append("\\s*").append(prefixPatternBuilder);
+ }
+ }
+ return suffixBuilder;
+ }
+
+ private synchronized long getMultiplierValue(String[] parts, String multiplier) {
+ for (String part : parts) {
+ final String[] pair = part.split(ARROW);
+ final String pair0 = pair[0].trim();
+ final String pair1 = pair[1].trim();
+
+ if (pair.length == 2 && pair0.equals(multiplier) && !pair1.equals(VIEWS)) {
+ return Long.parseLong(pair[1].replaceAll("[^\\d]", ""));
+ }
+ }
+
+ return 1L; // Default value if not found
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/KeywordContentFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/KeywordContentFilter.java
new file mode 100644
index 0000000000..bef4712de0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/KeywordContentFilter.java
@@ -0,0 +1,632 @@
+package app.revanced.extension.youtube.patches.components;
+
+import static java.lang.Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS;
+import static java.lang.Character.UnicodeBlock.HIRAGANA;
+import static java.lang.Character.UnicodeBlock.KATAKANA;
+import static java.lang.Character.UnicodeBlock.KHMER;
+import static java.lang.Character.UnicodeBlock.LAO;
+import static java.lang.Character.UnicodeBlock.MYANMAR;
+import static java.lang.Character.UnicodeBlock.THAI;
+import static java.lang.Character.UnicodeBlock.TIBETAN;
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.shared.utils.ByteTrieSearch;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.StringTrieSearch;
+import app.revanced.extension.shared.utils.TrieSearch;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.RootView;
+
+/**
+ *
+ * Allows hiding home feed and search results based on video title keywords and/or channel names.
+ *
+ * Limitations:
+ * - Searching for a keyword phrase will give no search results.
+ * This is because the buffer for each video contains the text the user searched for, and everything
+ * will be filtered away (even if that video title/channel does not contain any keywords).
+ * - Filtering a channel name can still show Shorts from that channel in the search results.
+ * The most common Shorts layouts do not include the channel name, so they will not be filtered.
+ * - Some layout component residue will remain, such as the video chapter previews for some search results.
+ * These components do not include the video title or channel name, and they
+ * appear outside the filtered components so they are not caught.
+ * - Keywords are case sensitive, but some casing variation is manually added.
+ * (ie: "mr beast" automatically filters "Mr Beast" and "MR BEAST").
+ * - Keywords present in the layout or video data cannot be used as filters, otherwise all videos
+ * will always be hidden. This patch checks for some words of these words.
+ * - When using whole word syntax, some keywords may need additional pluralized variations.
+ */
+@SuppressWarnings("unused")
+public final class KeywordContentFilter extends Filter {
+
+ /**
+ * Strings found in the buffer for every videos. Full strings should be specified.
+ *
+ * This list does not include every common buffer string, and this can be added/changed as needed.
+ * Words must be entered with the exact casing as found in the buffer.
+ */
+ private static final String[] STRINGS_IN_EVERY_BUFFER = {
+ // Video playback data.
+ "googlevideo.com/initplayback?source=youtube", // Video url.
+ "ANDROID", // Video url parameter.
+ "https://i.ytimg.com/vi/", // Thumbnail url.
+ "mqdefault.jpg",
+ "hqdefault.jpg",
+ "sddefault.jpg",
+ "hq720.jpg",
+ "webp",
+ "_custom_", // Custom thumbnail set by video creator.
+ // Video decoders.
+ "OMX.ffmpeg.vp9.decoder",
+ "OMX.Intel.sw_vd.vp9",
+ "OMX.MTK.VIDEO.DECODER.SW.VP9",
+ "OMX.google.vp9.decoder",
+ "OMX.google.av1.decoder",
+ "OMX.sprd.av1.decoder",
+ "c2.android.av1.decoder",
+ "c2.android.av1-dav1d.decoder",
+ "c2.android.vp9.decoder",
+ "c2.mtk.sw.vp9.decoder",
+ // Analytics.
+ "searchR",
+ "browse-feed",
+ "FEwhat_to_watch",
+ "FEsubscriptions",
+ "search_vwc_description_transition_key",
+ "g-high-recZ",
+ // Text and litho components found in the buffer that belong to path filters.
+ "expandable_metadata.eml",
+ "thumbnail.eml",
+ "avatar.eml",
+ "overflow_button.eml",
+ "shorts-lockup-image",
+ "shorts-lockup.overlay-metadata.secondary-text",
+ "YouTubeSans-SemiBold",
+ "sans-serif"
+ };
+
+ /**
+ * Substrings that are always first in the identifier.
+ */
+ private final StringFilterGroup startsWithFilter = new StringFilterGroup(
+ null, // Multiple settings are used and must be individually checked if active.
+ "video_lockup_with_attachment.eml",
+ "compact_video.eml",
+ "inline_shorts",
+ "shorts_video_cell",
+ "shorts_pivot_item.eml"
+ );
+
+ /**
+ * Substrings that are never at the start of the path.
+ */
+ @SuppressWarnings("FieldCanBeLocal")
+ private final StringFilterGroup containsFilter = new StringFilterGroup(
+ null,
+ "modern_type_shelf_header_content.eml",
+ "shorts_lockup_cell.eml", // Part of 'shorts_shelf_carousel.eml'
+ "video_card.eml" // Shorts that appear in a horizontal shelf.
+ );
+
+ /**
+ * Path components to not filter. Cannot filter the buffer when these are present,
+ * otherwise text in UI controls can be filtered as a keyword (such as using "Playlist" as a keyword).
+ *
+ * This is also a small performance improvement since
+ * the buffer of the parent component was already searched and passed.
+ */
+ private final StringTrieSearch exceptions = new StringTrieSearch(
+ "metadata.eml",
+ "thumbnail.eml",
+ "avatar.eml",
+ "overflow_button.eml"
+ );
+
+ /**
+ * Minimum keyword/phrase length to prevent excessively broad content filtering.
+ * Only applies when not using whole word syntax.
+ */
+ private static final int MINIMUM_KEYWORD_LENGTH = 3;
+
+ /**
+ * Threshold for {@link #filteredVideosPercentage}
+ * that indicates all or nearly all videos have been filtered.
+ * This should be close to 100% to reduce false positives.
+ */
+ private static final float ALL_VIDEOS_FILTERED_THRESHOLD = 0.95f;
+
+ private static final float ALL_VIDEOS_FILTERED_SAMPLE_SIZE = 50;
+
+ private static final long ALL_VIDEOS_FILTERED_BACKOFF_MILLISECONDS = 60 * 1000; // 60 seconds
+
+ private static final int UTF8_MAX_BYTE_COUNT = 4;
+
+ /**
+ * Rolling average of how many videos were filtered by a keyword.
+ * Used to detect if a keyword passes the initial check against {@link #STRINGS_IN_EVERY_BUFFER}
+ * but a keyword is still hiding all videos.
+ *
+ * This check can still fail if some extra UI elements pass the keywords,
+ * such as the video chapter preview or any other elements.
+ *
+ * To test this, add a filter that appears in all videos (such as 'ovd='),
+ * and open the subscription feed. In practice this does not always identify problems
+ * in the home feed and search, because the home feed has a finite amount of content and
+ * search results have a lot of extra video junk that is not hidden and interferes with the detection.
+ */
+ private volatile float filteredVideosPercentage;
+
+ /**
+ * If filtering is temporarily turned off, the time to resume filtering.
+ * Field is zero if no timeout is in effect.
+ */
+ private volatile long timeToResumeFiltering;
+
+ private final StringFilterGroup commentsFilter;
+
+ private final StringTrieSearch commentsFilterExceptions = new StringTrieSearch();
+
+ /**
+ * The last value of {@link Settings#HIDE_KEYWORD_CONTENT_PHRASES}
+ * parsed and loaded into {@link #bufferSearch}.
+ * Allows changing the keywords without restarting the app.
+ */
+ private volatile String lastKeywordPhrasesParsed;
+
+ private volatile ByteTrieSearch bufferSearch;
+
+ private static void logNavigationState(String state) {
+ // Enable locally to debug filtering. Default off to reduce log spam.
+ final boolean LOG_NAVIGATION_STATE = false;
+ // noinspection ConstantValue
+ if (LOG_NAVIGATION_STATE) {
+ Logger.printDebug(() -> "Navigation state: " + state);
+ }
+ }
+
+ /**
+ * Change first letter of the first word to use title case.
+ */
+ private static String titleCaseFirstWordOnly(String sentence) {
+ if (sentence.isEmpty()) {
+ return sentence;
+ }
+ final int firstCodePoint = sentence.codePointAt(0);
+ // In some non English languages title case is different than uppercase.
+ return new StringBuilder()
+ .appendCodePoint(Character.toTitleCase(firstCodePoint))
+ .append(sentence, Character.charCount(firstCodePoint), sentence.length())
+ .toString();
+ }
+
+ /**
+ * Uppercase the first letter of each word.
+ */
+ private static String capitalizeAllFirstLetters(String sentence) {
+ if (sentence.isEmpty()) {
+ return sentence;
+ }
+
+ final int delimiter = ' ';
+ // Use code points and not characters to handle unicode surrogates.
+ int[] codePoints = sentence.codePoints().toArray();
+ boolean capitalizeNext = true;
+ for (int i = 0, length = codePoints.length; i < length; i++) {
+ final int codePoint = codePoints[i];
+ if (codePoint == delimiter) {
+ capitalizeNext = true;
+ } else if (capitalizeNext) {
+ codePoints[i] = Character.toUpperCase(codePoint);
+ capitalizeNext = false;
+ }
+ }
+ return new String(codePoints, 0, codePoints.length);
+ }
+
+ /**
+ * @return If the string contains any characters from languages that do not use spaces between words.
+ */
+ private static boolean isLanguageWithNoSpaces(String text) {
+ for (int i = 0, length = text.length(); i < length; ) {
+ final int codePoint = text.codePointAt(i);
+
+ Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint);
+ if (block == CJK_UNIFIED_IDEOGRAPHS // Chinese and Kanji
+ || block == HIRAGANA // Japanese Hiragana
+ || block == KATAKANA // Japanese Katakana
+ || block == THAI
+ || block == LAO
+ || block == MYANMAR
+ || block == KHMER
+ || block == TIBETAN) {
+ return true;
+ }
+
+ i += Character.charCount(codePoint);
+ }
+
+ return false;
+ }
+
+
+ /**
+ * @return If the phrase will hide all videos. Not an exhaustive check.
+ */
+ private static boolean phrasesWillHideAllVideos(@NonNull String[] phrases, boolean matchWholeWords) {
+ for (String phrase : phrases) {
+ for (String commonString : STRINGS_IN_EVERY_BUFFER) {
+ if (matchWholeWords) {
+ byte[] commonStringBytes = commonString.getBytes(StandardCharsets.UTF_8);
+ int matchIndex = 0;
+ while (true) {
+ matchIndex = commonString.indexOf(phrase, matchIndex);
+ if (matchIndex < 0) break;
+
+ if (keywordMatchIsWholeWord(commonStringBytes, matchIndex, phrase.length())) {
+ return true;
+ }
+
+ matchIndex++;
+ }
+ } else if (Utils.containsAny(commonString, phrases)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @return If the start and end indexes are not surrounded by other letters.
+ * If the indexes are surrounded by numbers/symbols/punctuation it is considered a whole word.
+ */
+ private static boolean keywordMatchIsWholeWord(byte[] text, int keywordStartIndex, int keywordLength) {
+ final Integer codePointBefore = getUtf8CodePointBefore(text, keywordStartIndex);
+ if (codePointBefore != null && Character.isLetter(codePointBefore)) {
+ return false;
+ }
+
+ final Integer codePointAfter = getUtf8CodePointAt(text, keywordStartIndex + keywordLength);
+ //noinspection RedundantIfStatement
+ if (codePointAfter != null && Character.isLetter(codePointAfter)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * @return The UTF8 character point immediately before the index,
+ * or null if the bytes before the index is not a valid UTF8 character.
+ */
+ @Nullable
+ private static Integer getUtf8CodePointBefore(byte[] data, int index) {
+ int characterByteCount = 0;
+ while (--index >= 0 && ++characterByteCount <= UTF8_MAX_BYTE_COUNT) {
+ if (isValidUtf8(data, index, characterByteCount)) {
+ return decodeUtf8ToCodePoint(data, index, characterByteCount);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @return The UTF8 character point at the index,
+ * or null if the index holds no valid UTF8 character.
+ */
+ @Nullable
+ private static Integer getUtf8CodePointAt(byte[] data, int index) {
+ int characterByteCount = 0;
+ final int dataLength = data.length;
+ while (index + characterByteCount < dataLength && ++characterByteCount <= UTF8_MAX_BYTE_COUNT) {
+ if (isValidUtf8(data, index, characterByteCount)) {
+ return decodeUtf8ToCodePoint(data, index, characterByteCount);
+ }
+ }
+
+ return null;
+ }
+
+ public static boolean isValidUtf8(byte[] data, int startIndex, int numberOfBytes) {
+ switch (numberOfBytes) {
+ case 1 -> { // 0xxxxxxx (ASCII)
+ return (data[startIndex] & 0x80) == 0;
+ }
+ case 2 -> { // 110xxxxx, 10xxxxxx
+ return (data[startIndex] & 0xE0) == 0xC0
+ && (data[startIndex + 1] & 0xC0) == 0x80;
+ }
+ case 3 -> { // 1110xxxx, 10xxxxxx, 10xxxxxx
+ return (data[startIndex] & 0xF0) == 0xE0
+ && (data[startIndex + 1] & 0xC0) == 0x80
+ && (data[startIndex + 2] & 0xC0) == 0x80;
+ }
+ case 4 -> { // 11110xxx, 10xxxxxx, 10xxxxxx, 10xxxxxx
+ return (data[startIndex] & 0xF8) == 0xF0
+ && (data[startIndex + 1] & 0xC0) == 0x80
+ && (data[startIndex + 2] & 0xC0) == 0x80
+ && (data[startIndex + 3] & 0xC0) == 0x80;
+ }
+ }
+
+ throw new IllegalArgumentException("numberOfBytes: " + numberOfBytes);
+ }
+
+ public static int decodeUtf8ToCodePoint(byte[] data, int startIndex, int numberOfBytes) {
+ switch (numberOfBytes) {
+ case 1 -> {
+ return data[startIndex];
+ }
+ case 2 -> {
+ return ((data[startIndex] & 0x1F) << 6) |
+ (data[startIndex + 1] & 0x3F);
+ }
+ case 3 -> {
+ return ((data[startIndex] & 0x0F) << 12) |
+ ((data[startIndex + 1] & 0x3F) << 6) |
+ (data[startIndex + 2] & 0x3F);
+ }
+ case 4 -> {
+ return ((data[startIndex] & 0x07) << 18) |
+ ((data[startIndex + 1] & 0x3F) << 12) |
+ ((data[startIndex + 2] & 0x3F) << 6) |
+ (data[startIndex + 3] & 0x3F);
+ }
+ }
+ throw new IllegalArgumentException("numberOfBytes: " + numberOfBytes);
+ }
+
+ private static boolean phraseUsesWholeWordSyntax(String phrase) {
+ return phrase.startsWith("\"") && phrase.endsWith("\"");
+ }
+
+ private static String stripWholeWordSyntax(String phrase) {
+ return phrase.substring(1, phrase.length() - 1);
+ }
+
+ private synchronized void parseKeywords() { // Must be synchronized since Litho is multi-threaded.
+ String rawKeywords = Settings.HIDE_KEYWORD_CONTENT_PHRASES.get();
+
+ //noinspection StringEquality
+ if (rawKeywords == lastKeywordPhrasesParsed) {
+ Logger.printDebug(() -> "Using previously initialized search");
+ return; // Another thread won the race, and search is already initialized.
+ }
+
+ ByteTrieSearch search = new ByteTrieSearch();
+ String[] split = rawKeywords.split("\n");
+ if (split.length != 0) {
+ // Linked Set so log statement are more organized and easier to read.
+ // Map is: Phrase -> isWholeWord
+ Map keywords = new LinkedHashMap<>(10 * split.length);
+
+ for (String phrase : split) {
+ // Remove any trailing spaces the user may have accidentally included.
+ phrase = phrase.stripTrailing();
+ if (phrase.isBlank()) continue;
+
+ final boolean wholeWordMatching;
+ if (phraseUsesWholeWordSyntax(phrase)) {
+ if (phrase.length() == 2) {
+ continue; // Empty "" phrase
+ }
+ phrase = stripWholeWordSyntax(phrase);
+ wholeWordMatching = true;
+ } else if (phrase.length() < MINIMUM_KEYWORD_LENGTH && !isLanguageWithNoSpaces(phrase)) {
+ // Allow phrases of 1 and 2 characters if using a
+ // language that does not use spaces between words.
+
+ // Do not reset the setting. Keep the invalid keywords so the user can fix the mistake.
+ Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_length", phrase, MINIMUM_KEYWORD_LENGTH));
+ continue;
+ } else {
+ wholeWordMatching = false;
+ }
+
+ // Common casing that might appear.
+ //
+ // This could be simplified by adding case insensitive search to the prefix search,
+ // which is very simple to add to StringTreSearch for Unicode and ByteTrieSearch for ASCII.
+ //
+ // But to support Unicode with ByteTrieSearch would require major changes because
+ // UTF-8 characters can be different byte lengths, which does
+ // not allow comparing two different byte arrays using simple plain array indexes.
+ //
+ // Instead use all common case variations of the words.
+ String[] phraseVariations = {
+ phrase,
+ phrase.toLowerCase(),
+ titleCaseFirstWordOnly(phrase),
+ capitalizeAllFirstLetters(phrase),
+ phrase.toUpperCase()
+ };
+ if (phrasesWillHideAllVideos(phraseVariations, wholeWordMatching)) {
+ String toastMessage;
+ // If whole word matching is off, but would pass with on, then show a different toast.
+ if (!wholeWordMatching && !phrasesWillHideAllVideos(phraseVariations, true)) {
+ toastMessage = "revanced_hide_keyword_toast_invalid_common_whole_word_required";
+ } else {
+ toastMessage = "revanced_hide_keyword_toast_invalid_common";
+ }
+
+ Utils.showToastLong(str(toastMessage, phrase));
+ continue;
+ }
+
+ for (String variation : phraseVariations) {
+ // Check if the same phrase is declared both with and without quotes.
+ Boolean existing = keywords.get(variation);
+ if (existing == null) {
+ keywords.put(variation, wholeWordMatching);
+ } else if (existing != wholeWordMatching) {
+ Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_conflicting", phrase));
+ break;
+ }
+ }
+ }
+
+ for (Map.Entry entry : keywords.entrySet()) {
+ String keyword = entry.getKey();
+ //noinspection ExtractMethodRecommender
+ final boolean isWholeWord = entry.getValue();
+ TrieSearch.TriePatternMatchedCallback callback =
+ (textSearched, startIndex, matchLength, callbackParameter) -> {
+ if (isWholeWord && !keywordMatchIsWholeWord(textSearched, startIndex, matchLength)) {
+ return false;
+ }
+
+ Logger.printDebug(() -> (isWholeWord ? "Matched whole keyword: '"
+ : "Matched keyword: '") + keyword + "'");
+ // noinspection unchecked
+ ((MutableReference) callbackParameter).value = keyword;
+ return true;
+ };
+ byte[] stringBytes = keyword.getBytes(StandardCharsets.UTF_8);
+ search.addPattern(stringBytes, callback);
+ }
+
+ Logger.printDebug(() -> "Search using: (" + search.getEstimatedMemorySize() + " KB) keywords: " + keywords.keySet());
+ }
+
+ bufferSearch = search;
+ timeToResumeFiltering = 0;
+ filteredVideosPercentage = 0;
+ lastKeywordPhrasesParsed = rawKeywords; // Must set last.
+ }
+
+ public KeywordContentFilter() {
+ commentsFilterExceptions.addPatterns("engagement_toolbar");
+
+ commentsFilter = new StringFilterGroup(
+ Settings.HIDE_KEYWORD_CONTENT_COMMENTS,
+ "comment_thread.eml"
+ );
+
+ // Keywords are parsed on first call to isFiltered()
+ addPathCallbacks(startsWithFilter, containsFilter, commentsFilter);
+ }
+
+ private boolean hideKeywordSettingIsActive() {
+ if (timeToResumeFiltering != 0) {
+ if (System.currentTimeMillis() < timeToResumeFiltering) {
+ return false;
+ }
+
+ timeToResumeFiltering = 0;
+ filteredVideosPercentage = 0;
+ Logger.printDebug(() -> "Resuming keyword filtering");
+ }
+
+ final boolean hideHome = Settings.HIDE_KEYWORD_CONTENT_HOME.get();
+ final boolean hideSearch = Settings.HIDE_KEYWORD_CONTENT_SEARCH.get();
+ final boolean hideSubscriptions = Settings.HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS.get();
+
+ if (!hideHome && !hideSearch && !hideSubscriptions) {
+ return false;
+ } else if (hideHome && hideSearch && hideSubscriptions) {
+ return true;
+ }
+
+ // Must check player type first, as search bar can be active behind the player.
+ if (RootView.isPlayerActive()) {
+ // For now, consider the under video results the same as the home feed.
+ return hideHome;
+ }
+
+ // Must check second, as search can be from any tab.
+ if (RootView.isSearchBarActive()) {
+ return hideSearch;
+ }
+
+ NavigationButton selectedNavButton = NavigationButton.getSelectedNavigationButton();
+ if (selectedNavButton == null) {
+ return hideHome; // Unknown tab, treat the same as home.
+ }
+ if (selectedNavButton == NavigationButton.HOME) {
+ return hideHome;
+ }
+ if (selectedNavButton == NavigationButton.SUBSCRIPTIONS) {
+ return hideSubscriptions;
+ }
+ // User is in the Library or Notifications tab.
+ return false;
+ }
+
+ private void updateStats(boolean videoWasHidden, @Nullable String keyword) {
+ float updatedAverage = filteredVideosPercentage
+ * ((ALL_VIDEOS_FILTERED_SAMPLE_SIZE - 1) / ALL_VIDEOS_FILTERED_SAMPLE_SIZE);
+ if (videoWasHidden) {
+ updatedAverage += 1 / ALL_VIDEOS_FILTERED_SAMPLE_SIZE;
+ }
+
+ if (updatedAverage <= ALL_VIDEOS_FILTERED_THRESHOLD) {
+ filteredVideosPercentage = updatedAverage;
+ return;
+ }
+
+ // A keyword is hiding everything.
+ // Inform the user, and temporarily turn off filtering.
+ timeToResumeFiltering = System.currentTimeMillis() + ALL_VIDEOS_FILTERED_BACKOFF_MILLISECONDS;
+
+ Logger.printDebug(() -> "Temporarily turning off filtering due to excessively broad filter: " + keyword);
+ Utils.showToastLong(str("revanced_hide_keyword_toast_invalid_broad", keyword));
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (contentIndex != 0 && matchedGroup == startsWithFilter) {
+ return false;
+ }
+
+ // Do not filter if comments path includes an engagement toolbar (like, dislike...)
+ if (matchedGroup == commentsFilter && commentsFilterExceptions.matches(path)) {
+ return false;
+ }
+
+ // Field is intentionally compared using reference equality.
+ //noinspection StringEquality
+ if (Settings.HIDE_KEYWORD_CONTENT_PHRASES.get() != lastKeywordPhrasesParsed) {
+ // User changed the keywords or whole word setting.
+ parseKeywords();
+ }
+
+ if (matchedGroup != commentsFilter && !hideKeywordSettingIsActive()) {
+ return false;
+ }
+
+ if (exceptions.matches(path)) {
+ return false; // Do not update statistics.
+ }
+
+ MutableReference matchRef = new MutableReference<>();
+ if (bufferSearch.matches(protobufBufferArray, matchRef)) {
+ updateStats(true, matchRef.value);
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ updateStats(false, null);
+ return false;
+ }
+}
+
+/**
+ * Simple non-atomic wrapper since {@link AtomicReference#setPlain(Object)} is not available with Android 8.0.
+ */
+final class MutableReference {
+ T value;
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LayoutComponentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LayoutComponentsFilter.java
new file mode 100644
index 0000000000..f124060f01
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/LayoutComponentsFilter.java
@@ -0,0 +1,38 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class LayoutComponentsFilter extends Filter {
+ private static final String ACCOUNT_HEADER_PATH = "account_header.eml";
+
+ public LayoutComponentsFilter() {
+ addIdentifierCallbacks(
+ new StringFilterGroup(
+ Settings.HIDE_GRAY_SEPARATOR,
+ "cell_divider"
+ )
+ );
+
+ addPathCallbacks(
+ new StringFilterGroup(
+ Settings.HIDE_HANDLE,
+ "|CellType|ContainerType|ContainerType|ContainerType|TextType|"
+ )
+ );
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (contentType == FilterContentType.PATH && !path.startsWith(ACCOUNT_HEADER_PATH)) {
+ return false;
+ }
+
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlaybackSpeedMenuFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlaybackSpeedMenuFilter.java
new file mode 100644
index 0000000000..87a0472996
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlaybackSpeedMenuFilter.java
@@ -0,0 +1,52 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.youtube.patches.video.CustomPlaybackSpeedPatch;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * Abuse LithoFilter for {@link CustomPlaybackSpeedPatch}.
+ */
+public final class PlaybackSpeedMenuFilter extends Filter {
+ /**
+ * Old litho based speed selection menu.
+ */
+ public static volatile boolean isOldPlaybackSpeedMenuVisible;
+
+ /**
+ * 0.05x speed selection menu.
+ */
+ public static volatile boolean isPlaybackRateSelectorMenuVisible;
+
+ private final StringFilterGroup oldPlaybackMenuGroup;
+
+ public PlaybackSpeedMenuFilter() {
+ // 0.05x litho speed menu.
+ final StringFilterGroup playbackRateSelectorGroup = new StringFilterGroup(
+ Settings.ENABLE_CUSTOM_PLAYBACK_SPEED,
+ "playback_rate_selector_menu_sheet.eml-js"
+ );
+
+ // Old litho based speed menu.
+ oldPlaybackMenuGroup = new StringFilterGroup(
+ Settings.ENABLE_CUSTOM_PLAYBACK_SPEED,
+ "playback_speed_sheet_content.eml-js");
+
+ addPathCallbacks(playbackRateSelectorGroup, oldPlaybackMenuGroup);
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (matchedGroup == oldPlaybackMenuGroup) {
+ isOldPlaybackSpeedMenuVisible = true;
+ } else {
+ isPlaybackRateSelectorMenuVisible = true;
+ }
+
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerComponentsFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerComponentsFilter.java
new file mode 100644
index 0000000000..835b709d52
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerComponentsFilter.java
@@ -0,0 +1,129 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.shared.patches.components.StringFilterGroupList;
+import app.revanced.extension.shared.utils.StringTrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+
+@SuppressWarnings("unused")
+public final class PlayerComponentsFilter extends Filter {
+ private final StringFilterGroupList channelBarGroupList = new StringFilterGroupList();
+ private final StringFilterGroup channelBar;
+ private final StringTrieSearch suggestedActionsException = new StringTrieSearch();
+ private final StringFilterGroup suggestedActions;
+
+ public PlayerComponentsFilter() {
+ suggestedActionsException.addPatterns(
+ "channel_bar",
+ "shorts"
+ );
+
+ // The player audio track button does the exact same function as the audio track flyout menu option.
+ // But if the copy url button is shown, these button clashes and the the audio button does not work.
+ // Previously this was a setting to show/hide the player button.
+ // But it was decided it's simpler to always hide this button because:
+ // - it doesn't work with copy video url feature
+ // - the button is rare
+ // - always hiding makes the ReVanced settings simpler and easier to understand
+ // - nobody is going to notice the redundant button is always hidden
+ final StringFilterGroup audioTrackButton = new StringFilterGroup(
+ null,
+ "multi_feed_icon_button"
+ );
+
+ channelBar = new StringFilterGroup(
+ null,
+ "channel_bar_inner"
+ );
+
+ final StringFilterGroup channelWaterMark = new StringFilterGroup(
+ Settings.HIDE_CHANNEL_WATERMARK,
+ "featured_channel_watermark_overlay.eml"
+ );
+
+ final StringFilterGroup infoCards = new StringFilterGroup(
+ Settings.HIDE_INFO_CARDS,
+ "info_card_teaser_overlay.eml"
+ );
+
+ final StringFilterGroup infoPanel = new StringFilterGroup(
+ Settings.HIDE_INFO_PANEL,
+ "compact_banner",
+ "publisher_transparency_panel",
+ "single_item_information_panel"
+ );
+
+ final StringFilterGroup liveChat = new StringFilterGroup(
+ Settings.HIDE_LIVE_CHAT_MESSAGES,
+ "live_chat_text_message",
+ "viewer_engagement_message" // message about poll, not poll itself
+ );
+
+ final StringFilterGroup medicalPanel = new StringFilterGroup(
+ Settings.HIDE_MEDICAL_PANEL,
+ "emergency_onebox",
+ "medical_panel"
+ );
+
+ suggestedActions = new StringFilterGroup(
+ Settings.HIDE_SUGGESTED_ACTION,
+ "|suggested_action.eml|"
+ );
+
+ final StringFilterGroup timedReactions = new StringFilterGroup(
+ Settings.HIDE_TIMED_REACTIONS,
+ "emoji_control_panel",
+ "timed_reaction"
+ );
+
+ addPathCallbacks(
+ audioTrackButton,
+ channelBar,
+ channelWaterMark,
+ infoCards,
+ infoPanel,
+ liveChat,
+ medicalPanel,
+ suggestedActions,
+ timedReactions
+ );
+
+ final StringFilterGroup joinMembership = new StringFilterGroup(
+ Settings.HIDE_JOIN_BUTTON,
+ "compact_sponsor_button",
+ "|ContainerType|button.eml|"
+ );
+
+ final StringFilterGroup startTrial = new StringFilterGroup(
+ Settings.HIDE_START_TRIAL_BUTTON,
+ "channel_purchase_button"
+ );
+
+ channelBarGroupList.addAll(
+ joinMembership,
+ startTrial
+ );
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (matchedGroup == suggestedActions) {
+ // suggested actions button on shorts and the suggested actions button on video players use the same path builder.
+ // Check PlayerType to make each setting work independently.
+ if (suggestedActionsException.matches(path) || PlayerType.getCurrent().isNoneOrHidden()) {
+ return false;
+ }
+ } else if (matchedGroup == channelBar) {
+ if (!channelBarGroupList.check(path).isFiltered()) {
+ return false;
+ }
+ }
+
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerFlyoutMenuFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerFlyoutMenuFilter.java
new file mode 100644
index 0000000000..a3bbafdfd8
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/PlayerFlyoutMenuFilter.java
@@ -0,0 +1,170 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+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.StringTrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+
+@SuppressWarnings("unused")
+public final class PlayerFlyoutMenuFilter extends Filter {
+ private final ByteArrayFilterGroupList flyoutFilterGroupList = new ByteArrayFilterGroupList();
+
+ private final ByteArrayFilterGroup byteArrayException;
+ private final StringTrieSearch pathBuilderException = new StringTrieSearch();
+ private final StringTrieSearch playerFlyoutMenuFooter = new StringTrieSearch();
+ private final StringFilterGroup playerFlyoutMenu;
+ private final StringFilterGroup qualityHeader;
+
+ public PlayerFlyoutMenuFilter() {
+ byteArrayException = new ByteArrayFilterGroup(
+ null,
+ "quality_sheet"
+ );
+ pathBuilderException.addPattern(
+ "bottom_sheet_list_option"
+ );
+ playerFlyoutMenuFooter.addPatterns(
+ "captions_sheet_content.eml",
+ "quality_sheet_content.eml"
+ );
+
+ final StringFilterGroup captionsFooter = new StringFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_CAPTIONS_FOOTER,
+ "|ContainerType|ContainerType|ContainerType|TextType|",
+ "|divider.eml|"
+ );
+
+ final StringFilterGroup qualityFooter = new StringFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_QUALITY_FOOTER,
+ "quality_sheet_footer.eml",
+ "|divider.eml|"
+ );
+
+ qualityHeader = new StringFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_QUALITY_HEADER,
+ "quality_sheet_header.eml"
+ );
+
+ playerFlyoutMenu = new StringFilterGroup(null, "overflow_menu_item.eml|");
+
+ // Using pathFilterGroupList due to new flyout panel(A/B)
+ addPathCallbacks(
+ captionsFooter,
+ qualityFooter,
+ qualityHeader,
+ playerFlyoutMenu
+ );
+
+ flyoutFilterGroupList.addAll(
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_AMBIENT,
+ "yt_outline_screen_light"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_AUDIO_TRACK,
+ "yt_outline_person_radar"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_CAPTIONS,
+ "closed_caption"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_HELP,
+ "yt_outline_question_circle"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_LOCK_SCREEN,
+ "yt_outline_lock"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_LOOP,
+ "yt_outline_arrow_repeat_1_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_MORE,
+ "yt_outline_info_circle"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_PIP,
+ "yt_fill_picture_in_picture",
+ "yt_outline_picture_in_picture"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_PLAYBACK_SPEED,
+ "yt_outline_play_arrow_half_circle"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_PREMIUM_CONTROLS,
+ "yt_outline_adjust"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_ADDITIONAL_SETTINGS,
+ "yt_outline_gear"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_REPORT,
+ "yt_outline_flag"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_STABLE_VOLUME,
+ "volume_stable"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_SLEEP_TIMER,
+ "yt_outline_moon_z_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_STATS_FOR_NERDS,
+ "yt_outline_statistics_graph"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_WATCH_IN_VR,
+ "yt_outline_vr"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_YT_MUSIC,
+ "yt_outline_open_new"
+ )
+ );
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (matchedGroup == playerFlyoutMenu) {
+ // Overflow menu is always the start of the path.
+ if (contentIndex != 0) {
+ return false;
+ }
+ // Shorts also use this player flyout panel
+ if (PlayerType.getCurrent().isNoneOrHidden() || byteArrayException.check(protobufBufferArray).isFiltered()) {
+ return false;
+ }
+ if (flyoutFilterGroupList.check(protobufBufferArray).isFiltered()) {
+ // Super class handles logging.
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ } else if (matchedGroup == qualityHeader) {
+ // Quality header is always the start of the path.
+ if (contentIndex != 0) {
+ return false;
+ }
+ // Super class handles logging.
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ } else {
+ // Components other than the footer separator are not filtered.
+ if (pathBuilderException.matches(path) || !playerFlyoutMenuFooter.matches(path)) {
+ return false;
+ }
+ // Super class handles logging.
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/QuickActionFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/QuickActionFilter.java
new file mode 100644
index 0000000000..f86e2dfced
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/QuickActionFilter.java
@@ -0,0 +1,117 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+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.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class QuickActionFilter extends Filter {
+ private static final String QUICK_ACTION_PATH = "quick_actions.eml";
+ private final StringFilterGroup quickActionRule;
+
+ private final StringFilterGroup bufferFilterPathRule;
+ private final ByteArrayFilterGroupList bufferButtonsGroupList = new ByteArrayFilterGroupList();
+
+ private final StringFilterGroup liveChatReplay;
+
+ public QuickActionFilter() {
+ quickActionRule = new StringFilterGroup(null, QUICK_ACTION_PATH);
+ addIdentifierCallbacks(quickActionRule);
+ bufferFilterPathRule = new StringFilterGroup(
+ null,
+ "|ContainerType|button.eml|",
+ "|fullscreen_video_action_button.eml|"
+ );
+
+ liveChatReplay = new StringFilterGroup(
+ Settings.HIDE_LIVE_CHAT_REPLAY_BUTTON,
+ "live_chat_ep_entrypoint.eml"
+ );
+
+ addIdentifierCallbacks(liveChatReplay);
+
+ addPathCallbacks(
+ new StringFilterGroup(
+ Settings.HIDE_QUICK_ACTIONS_LIKE_BUTTON,
+ "|like_button"
+ ),
+ new StringFilterGroup(
+ Settings.HIDE_QUICK_ACTIONS_DISLIKE_BUTTON,
+ "dislike_button"
+ ),
+ new StringFilterGroup(
+ Settings.HIDE_QUICK_ACTIONS_COMMENT_BUTTON,
+ "comments_entry_point_button"
+ ),
+ new StringFilterGroup(
+ Settings.HIDE_QUICK_ACTIONS_SAVE_TO_PLAYLIST_BUTTON,
+ "|save_to_playlist_button"
+ ),
+ new StringFilterGroup(
+ Settings.HIDE_QUICK_ACTIONS_MORE_BUTTON,
+ "|overflow_menu_button"
+ ),
+ new StringFilterGroup(
+ Settings.HIDE_RELATED_VIDEO_OVERLAY,
+ "fullscreen_related_videos"
+ ),
+ bufferFilterPathRule
+ );
+
+ bufferButtonsGroupList.addAll(
+ new ByteArrayFilterGroup(
+ Settings.HIDE_QUICK_ACTIONS_COMMENT_BUTTON,
+ "yt_outline_message_bubble_right"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_QUICK_ACTIONS_LIVE_CHAT_BUTTON,
+ "yt_outline_message_bubble_overlap"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_QUICK_ACTIONS_OPEN_MIX_PLAYLIST_BUTTON,
+ "yt_outline_youtube_mix"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_QUICK_ACTIONS_OPEN_PLAYLIST_BUTTON,
+ "yt_outline_list_play_arrow"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_QUICK_ACTIONS_SHARE_BUTTON,
+ "yt_outline_share"
+ )
+ );
+ }
+
+ private boolean isEveryFilterGroupEnabled() {
+ for (StringFilterGroup group : pathCallbacks)
+ if (!group.isEnabled()) return false;
+
+ for (ByteArrayFilterGroup group : bufferButtonsGroupList)
+ if (!group.isEnabled()) return false;
+
+ return true;
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (matchedGroup == liveChatReplay) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ if (!path.startsWith(QUICK_ACTION_PATH)) {
+ return false;
+ }
+ if (matchedGroup == quickActionRule && !isEveryFilterGroupEnabled()) {
+ return false;
+ }
+ if (matchedGroup == bufferFilterPathRule) {
+ return bufferButtonsGroupList.check(protobufBufferArray).isFiltered();
+ }
+
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/RelatedVideoFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/RelatedVideoFilter.java
new file mode 100644
index 0000000000..af9a2fc4c9
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/RelatedVideoFilter.java
@@ -0,0 +1,55 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.shared.PlayerType;
+
+/**
+ * Here is an unintended behavior:
+ *
+ * 1. The user does not hide Shorts in the Subscriptions tab, but hides them otherwise.
+ * 2. Goes to the Subscriptions tab and scrolls to where Shorts is.
+ * 3. Opens a regular video.
+ * 4. Minimizes the video and turns off the screen.
+ * 5. Turns the screen on and maximizes the video.
+ * 6. Shorts belonging to related videos are not hidden.
+ *
+ * Here is an explanation of this special issue:
+ *
+ * When the user minimizes the video, turns off the screen, and then turns it back on,
+ * the components below the player are reloaded, and at this moment the PlayerType is [WATCH_WHILE_MINIMIZED].
+ * (Shorts belonging to related videos are also reloaded)
+ * Since the PlayerType is [WATCH_WHILE_MINIMIZED] at this moment, the navigation tab is checked.
+ * (Even though PlayerType is [WATCH_WHILE_MINIMIZED], this is a Shorts belonging to a related video)
+ *
+ * As a workaround for this special issue, if a video actionbar is detected, which is one of the components below the player,
+ * it is treated as being in the same state as [WATCH_WHILE_MAXIMIZED].
+ */
+public final class RelatedVideoFilter extends Filter {
+ // Must be volatile or synchronized, as litho filtering runs off main thread and this field is then access from the main thread.
+ public static final AtomicBoolean isActionBarVisible = new AtomicBoolean(false);
+
+ public RelatedVideoFilter() {
+ addIdentifierCallbacks(
+ new StringFilterGroup(
+ null,
+ "video_action_bar.eml"
+ )
+ );
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (PlayerType.getCurrent() == PlayerType.WATCH_WHILE_MINIMIZED &&
+ isActionBarVisible.compareAndSet(false, true))
+ Utils.runOnMainThreadDelayed(() -> isActionBarVisible.compareAndSet(true, false), 750);
+
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeChannelNameFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeChannelNameFilterPatch.java
new file mode 100644
index 0000000000..a78ba0a710
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeChannelNameFilterPatch.java
@@ -0,0 +1,105 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import java.net.URLDecoder;
+
+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.youtube.patches.utils.ReturnYouTubeChannelNamePatch;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings({"unused", "CharsetObjectCanBeUsed"})
+public final class ReturnYouTubeChannelNameFilterPatch extends Filter {
+ private static final String DELIMITING_CHARACTER = "❙";
+ private static final String CHANNEL_ID_IDENTIFIER_CHARACTER = "UC";
+ private static final String CHANNEL_ID_IDENTIFIER_WITH_DELIMITING_CHARACTER =
+ DELIMITING_CHARACTER + CHANNEL_ID_IDENTIFIER_CHARACTER;
+ private static final String HANDLE_IDENTIFIER_CHARACTER = "@";
+ private static final String HANDLE_IDENTIFIER_WITH_DELIMITING_CHARACTER =
+ HANDLE_IDENTIFIER_CHARACTER + CHANNEL_ID_IDENTIFIER_CHARACTER;
+
+ private final ByteArrayFilterGroupList shortsChannelBarAvatarFilterGroup = new ByteArrayFilterGroupList();
+
+ public ReturnYouTubeChannelNameFilterPatch() {
+ addPathCallbacks(
+ new StringFilterGroup(Settings.REPLACE_CHANNEL_HANDLE, "|reel_channel_bar_inner.eml|")
+ );
+ shortsChannelBarAvatarFilterGroup.addAll(
+ new ByteArrayFilterGroup(Settings.REPLACE_CHANNEL_HANDLE, "/@")
+ );
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (shortsChannelBarAvatarFilterGroup.check(protobufBufferArray).isFiltered()) {
+ setLastShortsChannelId(protobufBufferArray);
+ }
+
+ return false;
+ }
+
+ private void setLastShortsChannelId(byte[] protobufBufferArray) {
+ try {
+ String[] splitArr;
+ final String bufferString = findAsciiStrings(protobufBufferArray);
+ splitArr = bufferString.split(CHANNEL_ID_IDENTIFIER_WITH_DELIMITING_CHARACTER);
+ if (splitArr.length < 2) {
+ return;
+ }
+ final String splitedBufferString = CHANNEL_ID_IDENTIFIER_CHARACTER + splitArr[1];
+ splitArr = splitedBufferString.split(HANDLE_IDENTIFIER_WITH_DELIMITING_CHARACTER);
+ if (splitArr.length < 2) {
+ return;
+ }
+ splitArr = splitArr[1].split(DELIMITING_CHARACTER);
+ if (splitArr.length < 1) {
+ return;
+ }
+ final String cachedHandle = HANDLE_IDENTIFIER_CHARACTER + splitArr[0];
+ splitArr = splitedBufferString.split(DELIMITING_CHARACTER);
+ if (splitArr.length < 1) {
+ return;
+ }
+ final String channelId = splitArr[0].replaceAll("\"", "").trim();
+ final String handle = URLDecoder.decode(cachedHandle, "UTF-8").trim();
+
+ ReturnYouTubeChannelNamePatch.setLastShortsChannelId(handle, channelId);
+ } catch (Exception ex) {
+ Logger.printException(() -> "setLastShortsChannelId failed", ex);
+ }
+ }
+
+ private String findAsciiStrings(byte[] buffer) {
+ StringBuilder builder = new StringBuilder(Math.max(100, buffer.length / 2));
+ builder.append("");
+
+ // Valid ASCII values (ignore control characters).
+ final int minimumAscii = 32; // 32 = space character
+ final int maximumAscii = 126; // 127 = delete character
+ final int minimumAsciiStringLength = 4; // Minimum length of an ASCII string to include.
+ String delimitingCharacter = "❙"; // Non ascii character, to allow easier log filtering.
+
+ final int length = buffer.length;
+ int start = 0;
+ int end = 0;
+ while (end < length) {
+ int value = buffer[end];
+ if (value < minimumAscii || value > maximumAscii || end == length - 1) {
+ if (end - start >= minimumAsciiStringLength) {
+ for (int i = start; i < end; i++) {
+ builder.append((char) buffer[i]);
+ }
+ builder.append(delimitingCharacter);
+ }
+ start = end + 1;
+ }
+ end++;
+ }
+ return builder.toString();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java
new file mode 100644
index 0000000000..cbebddb448
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ReturnYouTubeDislikeFilterPatch.java
@@ -0,0 +1,171 @@
+package app.revanced.extension.youtube.patches.components;
+
+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.FilterGroup;
+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.patches.utils.ReturnYouTubeDislikePatch;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.VideoInformation;
+
+/**
+ * @noinspection ALL
+ *
+ * Searches for video id's in the proto buffer of Shorts dislike.
+ *
+ * Because multiple litho dislike spans are created in the background
+ * (and also anytime litho refreshes the components, which is somewhat arbitrary),
+ * that makes the value of {@link VideoInformation#getVideoId()} and {@link VideoInformation#getPlayerResponseVideoId()}
+ * unreliable to determine which video id a Shorts litho span belongs to.
+ *
+ * But the correct video id does appear in the protobuffer just before a Shorts litho span is created.
+ *
+ * Once a way to asynchronously update litho text is found, this strategy will no longer be needed.
+ */
+public final class ReturnYouTubeDislikeFilterPatch extends Filter {
+
+ /**
+ * 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();
+
+ public ReturnYouTubeDislikeFilterPatch() {
+ // When a new Short is opened, the like buttons always seem to load before the dislike.
+ // But if swiping back to a previous video and liking/disliking, then only that single button reloads.
+ // So must check for both buttons.
+ addPathCallbacks(
+ new StringFilterGroup(null, "|shorts_like_button.eml"),
+ new StringFilterGroup(null, "|shorts_dislike_button.eml")
+ );
+
+ // After the likes icon name is some binary data and then the video id for that specific short.
+ videoIdFilterGroup.addAll(
+ // on_shadowed = Video was previously like/disliked before opening.
+ // off_shadowed = Video was not previously liked/disliked before opening.
+ new ByteArrayFilterGroup(null, "ic_right_like_on_shadowed"),
+ new ByteArrayFilterGroup(null, "ic_right_like_off_shadowed"),
+
+ new ByteArrayFilterGroup(null, "ic_right_dislike_on_shadowed"),
+ new ByteArrayFilterGroup(null, "ic_right_dislike_off_shadowed")
+ );
+ }
+
+ private volatile static String shortsVideoId = "";
+
+ 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 (!Settings.RYD_SHORTS.get()) {
+ return;
+ }
+ if (shortsVideoId.equals(newlyLoadedVideoId)) {
+ return;
+ }
+ Logger.printDebug(() -> "newShortsVideoStarted: " + newlyLoadedVideoId);
+ shortsVideoId = newlyLoadedVideoId;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void newPlayerResponseVideoId(String videoId, boolean isShortAndOpeningOrPlaying) {
+ try {
+ if (!isShortAndOpeningOrPlaying || !Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) {
+ return;
+ }
+ synchronized (lastVideoIds) {
+ if (lastVideoIds.put(videoId, Boolean.TRUE) == null) {
+ Logger.printDebug(() -> "New Short video id: " + videoId);
+ }
+ }
+ } 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 (!Settings.RYD_ENABLED.get() || !Settings.RYD_SHORTS.get()) {
+ return false;
+ }
+
+ FilterGroup.FilterGroupResult result = videoIdFilterGroup.check(protobufBufferArray);
+ if (result.isFiltered()) {
+ String matchedVideoId = findVideoId(protobufBufferArray);
+ // Matched video will be null if in incognito mode.
+ // Must pass a null id to correctly clear out the current video data.
+ // Otherwise if a Short is opened in non-incognito, then incognito is enabled and another Short is opened,
+ // the new incognito Short will show the old prior data.
+ ReturnYouTubeDislikePatch.setLastLithoShortsVideoId(matchedVideoId);
+ }
+
+ return false;
+ }
+
+ @Nullable
+ private String findVideoId(byte[] protobufBufferArray) {
+ synchronized (lastVideoIds) {
+ for (String videoId : lastVideoIds.keySet()) {
+ if (byteArrayContainsString(protobufBufferArray, videoId)) {
+ return videoId;
+ }
+ }
+
+ return null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShareSheetMenuFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShareSheetMenuFilter.java
new file mode 100644
index 0000000000..316b0db39b
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShareSheetMenuFilter.java
@@ -0,0 +1,33 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.youtube.patches.misc.ShareSheetPatch;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * Abuse LithoFilter for {@link ShareSheetPatch}.
+ */
+public final class ShareSheetMenuFilter extends Filter {
+ // Must be volatile or synchronized, as litho filtering runs off main thread and this field is then access from the main thread.
+ public static volatile boolean isShareSheetMenuVisible;
+
+ public ShareSheetMenuFilter() {
+ addIdentifierCallbacks(
+ new StringFilterGroup(
+ Settings.CHANGE_SHARE_SHEET,
+ "share_sheet_container.eml"
+ )
+ );
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ isShareSheetMenuVisible = true;
+
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsButtonFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsButtonFilter.java
new file mode 100644
index 0000000000..8ec39c4b52
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsButtonFilter.java
@@ -0,0 +1,274 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.StringUtils;
+
+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.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class ShortsButtonFilter extends Filter {
+ private final static String REEL_CHANNEL_BAR_PATH = "reel_channel_bar.eml";
+ private final static String REEL_LIVE_HEADER_PATH = "immersive_live_header.eml";
+ /**
+ * For paid promotion label and subscribe button that appears in the channel bar.
+ */
+ private final static String REEL_METAPANEL_PATH = "reel_metapanel.eml";
+
+ private final static String SHORTS_PAUSED_STATE_BUTTON_PATH = "|ScrollableContainerType|ContainerType|button.eml|";
+
+ private final StringFilterGroup subscribeButton;
+ private final StringFilterGroup joinButton;
+ private final StringFilterGroup pausedOverlayButtons;
+ private final StringFilterGroup metaPanelButton;
+ private final ByteArrayFilterGroupList pausedOverlayButtonsGroupList = new ByteArrayFilterGroupList();
+
+ private final StringFilterGroup suggestedAction;
+ private final ByteArrayFilterGroupList suggestedActionsGroupList = new ByteArrayFilterGroupList();
+
+ private final StringFilterGroup actionBar;
+ private final ByteArrayFilterGroupList videoActionButtonGroupList = new ByteArrayFilterGroupList();
+
+ private final ByteArrayFilterGroup useThisSoundButton = new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_USE_THIS_SOUND_BUTTON,
+ "yt_outline_camera"
+ );
+
+ public ShortsButtonFilter() {
+ StringFilterGroup floatingButton = new StringFilterGroup(
+ Settings.HIDE_SHORTS_FLOATING_BUTTON,
+ "floating_action_button"
+ );
+
+ addIdentifierCallbacks(floatingButton);
+
+ pausedOverlayButtons = new StringFilterGroup(
+ null,
+ "shorts_paused_state"
+ );
+
+ StringFilterGroup channelBar = new StringFilterGroup(
+ Settings.HIDE_SHORTS_CHANNEL_BAR,
+ REEL_CHANNEL_BAR_PATH
+ );
+
+ StringFilterGroup fullVideoLinkLabel = new StringFilterGroup(
+ Settings.HIDE_SHORTS_FULL_VIDEO_LINK_LABEL,
+ "reel_multi_format_link"
+ );
+
+ StringFilterGroup videoTitle = new StringFilterGroup(
+ Settings.HIDE_SHORTS_VIDEO_TITLE,
+ "shorts_video_title_item"
+ );
+
+ StringFilterGroup reelSoundMetadata = new StringFilterGroup(
+ Settings.HIDE_SHORTS_SOUND_METADATA_LABEL,
+ "reel_sound_metadata"
+ );
+
+ StringFilterGroup infoPanel = new StringFilterGroup(
+ Settings.HIDE_SHORTS_INFO_PANEL,
+ "shorts_info_panel_overview"
+ );
+
+ StringFilterGroup stickers = new StringFilterGroup(
+ Settings.HIDE_SHORTS_STICKERS,
+ "stickers_layer.eml"
+ );
+
+ StringFilterGroup liveHeader = new StringFilterGroup(
+ Settings.HIDE_SHORTS_LIVE_HEADER,
+ "immersive_live_header"
+ );
+
+ StringFilterGroup paidPromotionButton = new StringFilterGroup(
+ Settings.HIDE_SHORTS_PAID_PROMOTION_LABEL,
+ "reel_player_disclosure.eml"
+ );
+
+ metaPanelButton = new StringFilterGroup(
+ null,
+ "|ContainerType|button.eml|"
+ );
+
+ joinButton = new StringFilterGroup(
+ Settings.HIDE_SHORTS_JOIN_BUTTON,
+ "sponsor_button"
+ );
+
+ subscribeButton = new StringFilterGroup(
+ Settings.HIDE_SHORTS_SUBSCRIBE_BUTTON,
+ "subscribe_button"
+ );
+
+ actionBar = new StringFilterGroup(
+ null,
+ "shorts_action_bar"
+ );
+
+ suggestedAction = new StringFilterGroup(
+ null,
+ "|suggested_action_inner.eml|"
+ );
+
+ addPathCallbacks(
+ suggestedAction, actionBar, joinButton, subscribeButton, metaPanelButton,
+ paidPromotionButton, pausedOverlayButtons, channelBar, fullVideoLinkLabel,
+ videoTitle, reelSoundMetadata, infoPanel, liveHeader, stickers
+ );
+
+ //
+ // Action buttons
+ //
+ videoActionButtonGroupList.addAll(
+ // This also appears as the path item 'shorts_like_button.eml'
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_LIKE_BUTTON,
+ "reel_like_button",
+ "reel_like_toggled_button"
+ ),
+ // This also appears as the path item 'shorts_dislike_button.eml'
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_DISLIKE_BUTTON,
+ "reel_dislike_button",
+ "reel_dislike_toggled_button"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_COMMENTS_BUTTON,
+ "reel_comment_button"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_SHARE_BUTTON,
+ "reel_share_button"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_REMIX_BUTTON,
+ "reel_remix_button"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.DISABLE_SHORTS_LIKE_BUTTON_FOUNTAIN_ANIMATION,
+ "shorts_like_fountain"
+ )
+ );
+
+ //
+ // Paused overlay buttons.
+ //
+ pausedOverlayButtonsGroupList.addAll(
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_TRENDS_BUTTON,
+ "yt_outline_fire_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_SHOPPING_BUTTON,
+ "yt_outline_bag_"
+ )
+ );
+
+ //
+ // Suggested actions.
+ //
+ suggestedActionsGroupList.addAll(
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_TAGGED_PRODUCTS,
+ // Product buttons show pictures of the products, and does not have any unique icons to identify.
+ // Instead use a unique identifier found in the buffer.
+ "PAproduct_listZ"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_SHOP_BUTTON,
+ "yt_outline_bag_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_LOCATION_BUTTON,
+ "yt_outline_location_point_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_SAVE_MUSIC_BUTTON,
+ "yt_outline_list_add_",
+ "yt_outline_bookmark_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_SEARCH_SUGGESTIONS_BUTTON,
+ "yt_outline_search_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_SUPER_THANKS_BUTTON,
+ "yt_outline_dollar_sign_heart_"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_USE_TEMPLATE_BUTTON,
+ "yt_outline_template_add"
+ ),
+ new ByteArrayFilterGroup(
+ Settings.HIDE_SHORTS_GREEN_SCREEN_BUTTON,
+ "shorts_green_screen"
+ ),
+ useThisSoundButton
+ );
+ }
+
+ private boolean isEverySuggestedActionFilterEnabled() {
+ for (ByteArrayFilterGroup group : suggestedActionsGroupList)
+ if (!group.isEnabled()) return false;
+
+ return true;
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ if (matchedGroup == subscribeButton || matchedGroup == joinButton) {
+ // Selectively filter to avoid false positive filtering of other subscribe/join buttons.
+ if (StringUtils.startsWithAny(path, REEL_CHANNEL_BAR_PATH, REEL_LIVE_HEADER_PATH, REEL_METAPANEL_PATH)) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+
+ if (matchedGroup == metaPanelButton) {
+ if (path.startsWith(REEL_METAPANEL_PATH) && useThisSoundButton.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+
+ // Video action buttons (like, dislike, comment, share, remix) have the same path.
+ if (matchedGroup == actionBar) {
+ if (videoActionButtonGroupList.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+
+ if (matchedGroup == suggestedAction) {
+ if (isEverySuggestedActionFilterEnabled()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ // Suggested actions can be at the start or in the middle of a path.
+ if (suggestedActionsGroupList.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ }
+
+ if (matchedGroup == pausedOverlayButtons) {
+ if (Settings.HIDE_SHORTS_PAUSED_OVERLAY_BUTTONS.get()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ } else if (StringUtils.contains(path, SHORTS_PAUSED_STATE_BUTTON_PATH)) {
+ if (pausedOverlayButtonsGroupList.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ }
+ return false;
+ }
+
+ // Super class handles logging.
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsShelfFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsShelfFilter.java
new file mode 100644
index 0000000000..261c646215
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/ShortsShelfFilter.java
@@ -0,0 +1,188 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup;
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.StringTrieSearch;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
+import app.revanced.extension.youtube.shared.RootView;
+
+@SuppressWarnings("unused")
+public final class ShortsShelfFilter extends Filter {
+ private static final String BROWSE_ID_HISTORY = "FEhistory";
+ private static final String BROWSE_ID_LIBRARY = "FElibrary";
+ private static final String BROWSE_ID_NOTIFICATION_INBOX = "FEnotifications_inbox";
+ private static final String BROWSE_ID_SUBSCRIPTIONS = "FEsubscriptions";
+ private static final String CONVERSATION_CONTEXT_FEED_IDENTIFIER =
+ "horizontalCollectionSwipeProtector=null";
+ private static final String SHELF_HEADER_PATH = "shelf_header.eml";
+ private final StringFilterGroup channelProfile;
+ private final StringFilterGroup compactFeedVideoPath;
+ private final ByteArrayFilterGroup compactFeedVideoBuffer;
+ private final StringFilterGroup shelfHeaderIdentifier;
+ private final StringFilterGroup shelfHeaderPath;
+ private static final StringTrieSearch feedGroup = new StringTrieSearch();
+ private static final BooleanSetting hideShortsShelf = Settings.HIDE_SHORTS_SHELF;
+ private static final BooleanSetting hideChannel = Settings.HIDE_SHORTS_SHELF_CHANNEL;
+ private static final ByteArrayFilterGroup channelProfileShelfHeader =
+ new ByteArrayFilterGroup(
+ hideChannel,
+ "Shorts"
+ );
+
+ public ShortsShelfFilter() {
+ feedGroup.addPattern(CONVERSATION_CONTEXT_FEED_IDENTIFIER);
+
+ channelProfile = new StringFilterGroup(
+ hideChannel,
+ "shorts_pivot_item"
+ );
+
+ final StringFilterGroup shortsIdentifiers = new StringFilterGroup(
+ hideShortsShelf,
+ "shorts_shelf",
+ "inline_shorts",
+ "shorts_grid",
+ "shorts_video_cell"
+ );
+
+ shelfHeaderIdentifier = new StringFilterGroup(
+ hideShortsShelf,
+ SHELF_HEADER_PATH
+ );
+
+ addIdentifierCallbacks(channelProfile, shortsIdentifiers, shelfHeaderIdentifier);
+
+ compactFeedVideoPath = new StringFilterGroup(
+ hideShortsShelf,
+ // Shorts that appear in the feed/search when the device is using tablet layout.
+ "compact_video.eml",
+ // 'video_lockup_with_attachment.eml' is used instead of 'compact_video.eml' for some users. (A/B tests)
+ "video_lockup_with_attachment.eml",
+ // Search results that appear in a horizontal shelf.
+ "video_card.eml"
+ );
+
+ // Filter out items that use the 'frame0' thumbnail.
+ // This is a valid thumbnail for both regular videos and Shorts,
+ // but it appears these thumbnails are used only for Shorts.
+ compactFeedVideoBuffer = new ByteArrayFilterGroup(
+ hideShortsShelf,
+ "/frame0.jpg"
+ );
+
+ // Feed Shorts shelf header.
+ // Use a different filter group for this pattern, as it requires an additional check after matching.
+ shelfHeaderPath = new StringFilterGroup(
+ hideShortsShelf,
+ SHELF_HEADER_PATH
+ );
+
+ addPathCallbacks(compactFeedVideoPath, shelfHeaderPath);
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ final boolean playerActive = RootView.isPlayerActive();
+ final boolean searchBarActive = RootView.isSearchBarActive();
+ final NavigationButton navigationButton = NavigationButton.getSelectedNavigationButton();
+ final String navigation = navigationButton == null ? "null" : navigationButton.name();
+ final String browseId = RootView.getBrowseId();
+ final boolean hideShelves = shouldHideShortsFeedItems(playerActive, searchBarActive, navigationButton, browseId);
+ Logger.printDebug(() -> "hideShelves: " + hideShelves + "\nplayerActive: " + playerActive + "\nsearchBarActive: " + searchBarActive + "\nbrowseId: " + browseId + "\nnavigation: " + navigation);
+ if (contentType == FilterContentType.PATH) {
+ if (matchedGroup == compactFeedVideoPath) {
+ if (hideShelves && compactFeedVideoBuffer.check(protobufBufferArray).isFiltered()) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ return false;
+ } else if (matchedGroup == shelfHeaderPath) {
+ // Because the header is used in watch history and possibly other places, check for the index,
+ // which is 0 when the shelf header is used for Shorts.
+ if (contentIndex != 0) {
+ return false;
+ }
+ if (!channelProfileShelfHeader.check(protobufBufferArray).isFiltered()) {
+ return false;
+ }
+ if (feedGroup.matches(allValue)) {
+ return false;
+ }
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ } else if (contentType == FilterContentType.IDENTIFIER) {
+ // Feed/search identifier components.
+ if (matchedGroup == shelfHeaderIdentifier) {
+ // Check ConversationContext to not hide shelf header in channel profile
+ // This value does not exist in the shelf header in the channel profile
+ if (!feedGroup.matches(allValue)) {
+ return false;
+ }
+ } else if (matchedGroup == channelProfile) {
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+ if (!hideShelves) {
+ return false;
+ }
+ }
+
+ // Super class handles logging.
+ return super.isFiltered(path, identifier, allValue, protobufBufferArray, matchedGroup, contentType, contentIndex);
+ }
+
+ private static boolean shouldHideShortsFeedItems(boolean playerActive, boolean searchBarActive, NavigationButton selectedNavButton, String browseId) {
+ final boolean hideHomeAndRelatedVideos = Settings.HIDE_SHORTS_SHELF_HOME_RELATED_VIDEOS.get();
+ final boolean hideSubscriptions = Settings.HIDE_SHORTS_SHELF_SUBSCRIPTIONS.get();
+ final boolean hideSearch = Settings.HIDE_SHORTS_SHELF_SEARCH.get();
+
+ if (hideHomeAndRelatedVideos && hideSubscriptions && hideSearch) {
+ // Shorts suggestions can load in the background if a video is opened and
+ // then immediately minimized before any suggestions are loaded.
+ // In this state the player type will show minimized, which makes it not possible to
+ // distinguish between Shorts suggestions loading in the player and between
+ // scrolling thru search/home/subscription tabs while a player is minimized.
+ //
+ // To avoid this situation for users that never want to show Shorts (all hide Shorts options are enabled)
+ // then hide all Shorts everywhere including the Library history and Library playlists.
+ return true;
+ }
+
+ // Must check player type first, as search bar can be active behind the player.
+ if (playerActive) {
+ // For now, consider the under video results the same as the home feed.
+ return hideHomeAndRelatedVideos;
+ }
+
+ // Must check second, as search can be from any tab.
+ if (searchBarActive) {
+ return hideSearch;
+ }
+
+ // Avoid checking navigation button status if all other Shorts should show.
+ if (!hideHomeAndRelatedVideos && !hideSubscriptions) {
+ return false;
+ }
+
+ if (selectedNavButton == null) {
+ return hideHomeAndRelatedVideos; // Unknown tab, treat the same as home.
+ }
+
+ switch (browseId) {
+ case BROWSE_ID_HISTORY, BROWSE_ID_LIBRARY, BROWSE_ID_NOTIFICATION_INBOX -> {
+ return false;
+ }
+ case BROWSE_ID_SUBSCRIPTIONS -> {
+ return hideSubscriptions;
+ }
+ default -> {
+ return hideHomeAndRelatedVideos;
+ }
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/VideoQualityMenuFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/VideoQualityMenuFilter.java
new file mode 100644
index 0000000000..812eabbc4a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/components/VideoQualityMenuFilter.java
@@ -0,0 +1,33 @@
+package app.revanced.extension.youtube.patches.components;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.patches.components.Filter;
+import app.revanced.extension.shared.patches.components.StringFilterGroup;
+import app.revanced.extension.youtube.patches.video.RestoreOldVideoQualityMenuPatch;
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * Abuse LithoFilter for {@link RestoreOldVideoQualityMenuPatch}.
+ */
+public final class VideoQualityMenuFilter extends Filter {
+ // Must be volatile or synchronized, as litho filtering runs off main thread and this field is then access from the main thread.
+ public static volatile boolean isVideoQualityMenuVisible;
+
+ public VideoQualityMenuFilter() {
+ addPathCallbacks(
+ new StringFilterGroup(
+ Settings.RESTORE_OLD_VIDEO_QUALITY_MENU,
+ "quick_quality_sheet_content.eml-js"
+ )
+ );
+ }
+
+ @Override
+ public boolean isFiltered(String path, @Nullable String identifier, String allValue, byte[] protobufBufferArray,
+ StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
+ isVideoQualityMenuVisible = true;
+
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/feed/FeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/feed/FeedPatch.java
new file mode 100644
index 0000000000..46a494a877
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/feed/FeedPatch.java
@@ -0,0 +1,221 @@
+package app.revanced.extension.youtube.patches.feed;
+
+import static app.revanced.extension.shared.utils.Utils.hideViewBy0dpUnderCondition;
+import static app.revanced.extension.shared.utils.Utils.hideViewUnderCondition;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class FeedPatch {
+
+ // region [Hide feed components] patch
+
+ public static int hideCategoryBarInFeed(final int height) {
+ return Settings.HIDE_CATEGORY_BAR_IN_FEED.get() ? 0 : height;
+ }
+
+ public static void hideCategoryBarInRelatedVideos(final View chipView) {
+ Utils.hideViewBy0dpUnderCondition(
+ Settings.HIDE_CATEGORY_BAR_IN_RELATED_VIDEOS.get() || Settings.HIDE_RELATED_VIDEOS.get(),
+ chipView
+ );
+ }
+
+ public static int hideCategoryBarInSearch(final int height) {
+ return Settings.HIDE_CATEGORY_BAR_IN_SEARCH.get() ? 0 : height;
+ }
+
+ /**
+ * Rather than simply hiding the channel tab view, completely removes channel tab from list.
+ * If a channel tab is removed from the list, users will not be able to open it by swiping.
+ *
+ * @param channelTabText Text to be assigned to channel tab, such as 'Shorts', 'Playlists', 'Community', 'Store'.
+ * This text is hardcoded, so it follows the user's language.
+ * @return Whether to remove the channel tab from the list.
+ */
+ public static boolean hideChannelTab(String channelTabText) {
+ if (!Settings.HIDE_CHANNEL_TAB.get()) {
+ return false;
+ }
+ if (channelTabText == null || channelTabText.isEmpty()) {
+ return false;
+ }
+
+ String[] blockList = Settings.HIDE_CHANNEL_TAB_FILTER_STRINGS.get().split("\\n");
+
+ for (String filter : blockList) {
+ if (!filter.isEmpty() && channelTabText.equals(filter)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public static void hideBreakingNewsShelf(View view) {
+ hideViewBy0dpUnderCondition(
+ Settings.HIDE_CAROUSEL_SHELF.get(),
+ view
+ );
+ }
+
+ public static View hideCaptionsButton(View view) {
+ return Settings.HIDE_FEED_CAPTIONS_BUTTON.get() ? null : view;
+ }
+
+ public static void hideCaptionsButtonContainer(View view) {
+ hideViewUnderCondition(
+ Settings.HIDE_FEED_CAPTIONS_BUTTON,
+ view
+ );
+ }
+
+ public static boolean hideFloatingButton() {
+ return Settings.HIDE_FLOATING_BUTTON.get();
+ }
+
+ public static void hideLatestVideosButton(View view) {
+ hideViewUnderCondition(Settings.HIDE_LATEST_VIDEOS_BUTTON.get(), view);
+ }
+
+ public static boolean hideSubscriptionsChannelSection() {
+ return Settings.HIDE_SUBSCRIPTIONS_CAROUSEL.get();
+ }
+
+ public static void hideSubscriptionsChannelSection(View view) {
+ hideViewUnderCondition(Settings.HIDE_SUBSCRIPTIONS_CAROUSEL, view);
+ }
+
+ private static FrameLayout.LayoutParams layoutParams;
+ private static int minimumHeight = -1;
+ private static int paddingLeft = 12;
+ private static int paddingTop = 0;
+ private static int paddingRight = 12;
+ private static int paddingBottom = 0;
+
+ /**
+ * expandButtonContainer is used in channel profiles as well as search results.
+ * We need to hide expandButtonContainer only in search results, not in channel profile.
+ *
+ * If we hide expandButtonContainer with setVisibility, the empty space occupied by expandButtonContainer will still be left.
+ * Therefore, we need to dynamically resize the View with LayoutParams.
+ *
+ * Unlike other Views, expandButtonContainer cannot make a View invisible using the normal {@link Utils#hideViewByLayoutParams} method.
+ * We should set the parent view's padding and MinimumHeight to 0 to completely hide the expandButtonContainer.
+ *
+ * @param parentView Parent view of expandButtonContainer.
+ */
+ public static void hideShowMoreButton(View parentView) {
+ if (!Settings.HIDE_SHOW_MORE_BUTTON.get())
+ return;
+
+ if (!(parentView instanceof ViewGroup viewGroup))
+ return;
+
+ if (!(viewGroup.getChildAt(0) instanceof ViewGroup expandButtonContainer))
+ return;
+
+ if (layoutParams == null) {
+ // We need to get the original LayoutParams and paddings applied to expandButtonContainer.
+ // Theses are used to make the expandButtonContainer visible again.
+ if (expandButtonContainer.getLayoutParams() instanceof FrameLayout.LayoutParams lp) {
+ layoutParams = lp;
+ paddingLeft = parentView.getPaddingLeft();
+ paddingTop = parentView.getPaddingTop();
+ paddingRight = parentView.getPaddingRight();
+ paddingBottom = parentView.getPaddingBottom();
+ }
+ }
+
+ // I'm not sure if 'Utils.runOnMainThreadDelayed' is absolutely necessary.
+ Utils.runOnMainThreadDelayed(() -> {
+ // MinimumHeight is also needed to make expandButtonContainer visible again.
+ // Get original MinimumHeight.
+ if (minimumHeight == -1) {
+ minimumHeight = parentView.getMinimumHeight();
+ }
+
+ // In the search results, the child view structure of expandButtonContainer is as follows:
+ // expandButtonContainer
+ // L TextView (first child view is SHOWN, 'Show more' text)
+ // L ImageView (second child view is shown, dropdown arrow icon)
+
+ // In the channel profiles, the child view structure of expandButtonContainer is as follows:
+ // expandButtonContainer
+ // L TextView (first child view is HIDDEN, 'Show more' text)
+ // L ImageView (second child view is shown, dropdown arrow icon)
+
+ if (expandButtonContainer.getChildAt(0).getVisibility() != View.VISIBLE && layoutParams != null) {
+ // If the first child view (TextView) is HIDDEN, the channel profile is open.
+ // Restore parent view's padding and MinimumHeight to make them visible.
+ parentView.setMinimumHeight(minimumHeight);
+ parentView.setPadding(paddingLeft, paddingTop, paddingRight, paddingBottom);
+ expandButtonContainer.setLayoutParams(layoutParams);
+ } else {
+ // If the first child view (TextView) is SHOWN, the search results is open.
+ // Set the parent view's padding and MinimumHeight to 0 to completely hide the expandButtonContainer.
+ parentView.setMinimumHeight(0);
+ parentView.setPadding(0, 0, 0, 0);
+ expandButtonContainer.setLayoutParams(new FrameLayout.LayoutParams(0, 0));
+ }
+ }, 0
+ );
+ }
+
+ // endregion
+
+ // region [Hide feed flyout menu] patch
+
+ /**
+ * hide feed flyout menu for phone
+ *
+ * @param menuTitleCharSequence menu title
+ */
+ @Nullable
+ public static CharSequence hideFlyoutMenu(@Nullable CharSequence menuTitleCharSequence) {
+ if (menuTitleCharSequence != null && Settings.HIDE_FEED_FLYOUT_MENU.get()) {
+ String[] blockList = Settings.HIDE_FEED_FLYOUT_MENU_FILTER_STRINGS.get().split("\\n");
+ String menuTitleString = menuTitleCharSequence.toString();
+
+ for (String filter : blockList) {
+ if (menuTitleString.equals(filter) && !filter.isEmpty())
+ return null;
+ }
+ }
+
+ return menuTitleCharSequence;
+ }
+
+ /**
+ * hide feed flyout panel for tablet
+ *
+ * @param menuTextView flyout text view
+ * @param menuTitleCharSequence raw text
+ */
+ public static void hideFlyoutMenu(TextView menuTextView, CharSequence menuTitleCharSequence) {
+ if (menuTitleCharSequence == null || !Settings.HIDE_FEED_FLYOUT_MENU.get())
+ return;
+
+ if (!(menuTextView.getParent() instanceof View parentView))
+ return;
+
+ String[] blockList = Settings.HIDE_FEED_FLYOUT_MENU_FILTER_STRINGS.get().split("\\n");
+ String menuTitleString = menuTitleCharSequence.toString();
+
+ for (String filter : blockList) {
+ if (menuTitleString.equals(filter) && !filter.isEmpty())
+ Utils.hideViewByLayoutParams(parentView);
+ }
+ }
+
+ // endregion
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/feed/RelatedVideoPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/feed/RelatedVideoPatch.java
new file mode 100644
index 0000000000..ccc20a6311
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/feed/RelatedVideoPatch.java
@@ -0,0 +1,49 @@
+package app.revanced.extension.youtube.patches.feed;
+
+import androidx.annotation.Nullable;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.BottomSheetState;
+import app.revanced.extension.youtube.shared.RootView;
+
+@SuppressWarnings("unused")
+public final class RelatedVideoPatch {
+ private static final boolean HIDE_RELATED_VIDEOS = Settings.HIDE_RELATED_VIDEOS.get();
+
+ private static final int OFFSET = Settings.RELATED_VIDEOS_OFFSET.get();
+
+ // video title,channel bar, video action bar, comment
+ private static final int MAX_ITEM_COUNT = 4 + OFFSET;
+
+ private static final AtomicBoolean engagementPanelOpen = new AtomicBoolean(false);
+
+ public static void showEngagementPanel(@Nullable Object object) {
+ engagementPanelOpen.set(object != null);
+ }
+
+ public static void hideEngagementPanel() {
+ engagementPanelOpen.compareAndSet(true, false);
+ }
+
+ public static int overrideItemCounts(int itemCounts) {
+ if (!HIDE_RELATED_VIDEOS) {
+ return itemCounts;
+ }
+ if (itemCounts < MAX_ITEM_COUNT) {
+ return itemCounts;
+ }
+ if (!RootView.isPlayerActive()) {
+ return itemCounts;
+ }
+ if (BottomSheetState.getCurrent().isOpen()) {
+ return itemCounts;
+ }
+ if (engagementPanelOpen.get()) {
+ return itemCounts;
+ }
+ return MAX_ITEM_COUNT;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/ChangeStartPagePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/ChangeStartPagePatch.java
new file mode 100644
index 0000000000..272eac1dd4
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/ChangeStartPagePatch.java
@@ -0,0 +1,134 @@
+package app.revanced.extension.youtube.patches.general;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+
+import android.content.Intent;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.BooleanUtils;
+import org.apache.commons.lang3.StringUtils;
+
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class ChangeStartPagePatch {
+
+ public enum StartPage {
+ /**
+ * Unmodified type, and same as un-patched.
+ */
+ ORIGINAL("", null),
+
+ /**
+ * Browse id.
+ */
+ BROWSE("FEguide_builder", TRUE),
+ EXPLORE("FEexplore", TRUE),
+ HISTORY("FEhistory", TRUE),
+ LIBRARY("FElibrary", TRUE),
+ MOVIE("FEstorefront", TRUE),
+ SUBSCRIPTIONS("FEsubscriptions", TRUE),
+ TRENDING("FEtrending", TRUE),
+
+ /**
+ * Channel id, this can be used as a browseId.
+ */
+ GAMING("UCOpNcN46UbXVtpKMrmU4Abg", TRUE),
+ LIVE("UC4R8DWoMoI7CAwX8_LjQHig", TRUE),
+ MUSIC("UC-9-kyTW8ZkZNDHQJ6FgpwQ", TRUE),
+ SPORTS("UCEgdi0XIXXZ-qJOFPf4JSKw", TRUE),
+
+ /**
+ * Playlist id, this can be used as a browseId.
+ */
+ LIKED_VIDEO("VLLL", TRUE),
+ WATCH_LATER("VLWL", TRUE),
+
+ /**
+ * Intent action.
+ */
+ SEARCH("com.google.android.youtube.action.open.search", FALSE),
+ SHORTS("com.google.android.youtube.action.open.shorts", FALSE);
+
+ @Nullable
+ final Boolean isBrowseId;
+
+ @NonNull
+ final String id;
+
+ StartPage(@NonNull String id, @Nullable Boolean isBrowseId) {
+ this.id = id;
+ this.isBrowseId = isBrowseId;
+ }
+
+ private boolean isBrowseId() {
+ return BooleanUtils.isTrue(isBrowseId);
+ }
+
+ @SuppressWarnings("BooleanMethodIsAlwaysInverted")
+ private boolean isIntentAction() {
+ return BooleanUtils.isFalse(isBrowseId);
+ }
+ }
+
+ /**
+ * Intent action when YouTube is cold started from the launcher.
+ *
+ * If you don't check this, the hooking will also apply in the following cases:
+ * Case 1. The user clicked Shorts button on the YouTube shortcut.
+ * Case 2. The user clicked Shorts button on the YouTube widget.
+ * In this case, instead of opening Shorts, the start page specified by the user is opened.
+ */
+ private static final String ACTION_MAIN = "android.intent.action.MAIN";
+
+ private static final StartPage START_PAGE = Settings.CHANGE_START_PAGE.get();
+ private static final boolean ALWAYS_CHANGE_START_PAGE = Settings.CHANGE_START_PAGE_TYPE.get();
+
+ /**
+ * There is an issue where the back button on the toolbar doesn't work properly.
+ * As a workaround for this issue, instead of overriding the browserId multiple times, just override it once.
+ */
+ private static boolean appLaunched = false;
+
+ public static String overrideBrowseId(@NonNull String original) {
+ if (!START_PAGE.isBrowseId()) {
+ return original;
+ }
+ if (!ALWAYS_CHANGE_START_PAGE && appLaunched) {
+ Logger.printDebug(() -> "Ignore override browseId as the app already launched");
+ return original;
+ }
+ appLaunched = true;
+
+ final String browseId = START_PAGE.id;
+ Logger.printDebug(() -> "Changing browseId to " + browseId);
+ return browseId;
+ }
+
+ public static void overrideIntentAction(@NonNull Intent intent) {
+ if (!START_PAGE.isIntentAction()) {
+ return;
+ }
+ if (!StringUtils.equals(intent.getAction(), ACTION_MAIN)) {
+ Logger.printDebug(() -> "Ignore override intent action" +
+ " as the current activity is not the entry point of the application");
+ return;
+ }
+
+ final String intentAction = START_PAGE.id;
+ Logger.printDebug(() -> "Changing intent action to " + intentAction);
+ intent.setAction(intentAction);
+ }
+
+ public static final class ChangeStartPageTypeAvailability implements Setting.Availability {
+ @Override
+ public boolean isAvailable() {
+ return Settings.CHANGE_START_PAGE.get() != StartPage.ORIGINAL;
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/DownloadActionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/DownloadActionsPatch.java
new file mode 100644
index 0000000000..0c16075611
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/DownloadActionsPatch.java
@@ -0,0 +1,98 @@
+package app.revanced.extension.youtube.patches.general;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.utils.VideoUtils;
+
+@SuppressWarnings("unused")
+public final class DownloadActionsPatch extends VideoUtils {
+
+ private static final BooleanSetting overrideVideoDownloadButton =
+ Settings.OVERRIDE_VIDEO_DOWNLOAD_BUTTON;
+
+ private static final BooleanSetting overridePlaylistDownloadButton =
+ Settings.OVERRIDE_PLAYLIST_DOWNLOAD_BUTTON;
+
+ /**
+ * Injection point.
+ *
+ * Called from the in app download hook,
+ * for both the player action button (below the video)
+ * and the 'Download video' flyout option for feed videos.
+ *
+ * Appears to always be called from the main thread.
+ */
+ public static boolean inAppVideoDownloadButtonOnClick(String videoId) {
+ try {
+ if (!overrideVideoDownloadButton.get()) {
+ return false;
+ }
+ if (videoId == null || videoId.isEmpty()) {
+ return false;
+ }
+ launchVideoExternalDownloader(videoId);
+
+ return true;
+ } catch (Exception ex) {
+ Logger.printException(() -> "inAppVideoDownloadButtonOnClick failure", ex);
+ }
+ return false;
+ }
+
+ /**
+ * Injection point.
+ *
+ * Called from the in app playlist download hook.
+ *
+ * Appears to always be called from the main thread.
+ */
+ public static String inAppPlaylistDownloadButtonOnClick(String playlistId) {
+ try {
+ if (!overridePlaylistDownloadButton.get()) {
+ return playlistId;
+ }
+ if (playlistId == null || playlistId.isEmpty()) {
+ return playlistId;
+ }
+ launchPlaylistExternalDownloader(playlistId);
+
+ return "";
+ } catch (Exception ex) {
+ Logger.printException(() -> "inAppPlaylistDownloadButtonOnClick failure", ex);
+ }
+ return playlistId;
+ }
+
+ /**
+ * Injection point.
+ *
+ * Called from the 'Download playlist' flyout option.
+ *
+ * Appears to always be called from the main thread.
+ */
+ public static boolean inAppPlaylistDownloadMenuOnClick(String playlistId) {
+ try {
+ if (!overridePlaylistDownloadButton.get()) {
+ return false;
+ }
+ if (playlistId == null || playlistId.isEmpty()) {
+ return false;
+ }
+ launchPlaylistExternalDownloader(playlistId);
+
+ return true;
+ } catch (Exception ex) {
+ Logger.printException(() -> "inAppPlaylistDownloadMenuOnClick failure", ex);
+ }
+ return false;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean overridePlaylistDownloadButtonVisibility() {
+ return overridePlaylistDownloadButton.get();
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/GeneralPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/GeneralPatch.java
new file mode 100644
index 0000000000..cccf47d41e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/GeneralPatch.java
@@ -0,0 +1,589 @@
+package app.revanced.extension.youtube.patches.general;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.shared.utils.Utils.getChildView;
+import static app.revanced.extension.shared.utils.Utils.hideViewByLayoutParams;
+import static app.revanced.extension.shared.utils.Utils.hideViewGroupByMarginLayoutParams;
+import static app.revanced.extension.shared.utils.Utils.hideViewUnderCondition;
+import static app.revanced.extension.youtube.patches.utils.PatchStatus.ImageSearchButton;
+import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.util.TypedValue;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.ViewGroup.LayoutParams;
+import android.view.ViewGroup.MarginLayoutParams;
+import android.view.Window;
+import android.view.WindowManager;
+import android.widget.Button;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import com.google.android.apps.youtube.app.application.Shell_SettingsActivity;
+import com.google.android.apps.youtube.app.settings.SettingsActivity;
+import com.google.android.apps.youtube.app.settings.videoquality.VideoQualitySettingsActivity;
+
+import org.apache.commons.lang3.BooleanUtils;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.EnumMap;
+import java.util.Map;
+import java.util.Objects;
+
+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.settings.Settings;
+import app.revanced.extension.youtube.utils.ThemeUtils;
+
+@SuppressWarnings("unused")
+public class GeneralPatch {
+
+ // region [Disable auto audio tracks] patch
+
+ private static final String DEFAULT_AUDIO_TRACKS_IDENTIFIER = "original";
+ private static ArrayList formatStreamModelArray;
+
+ /**
+ * Find the stream format containing the parameter {@link GeneralPatch#DEFAULT_AUDIO_TRACKS_IDENTIFIER}, and save to the array.
+ *
+ * @param formatStreamModel stream format model including audio tracks.
+ */
+ public static void setFormatStreamModelArray(final Object formatStreamModel) {
+ if (!Settings.DISABLE_AUTO_AUDIO_TRACKS.get()) {
+ return;
+ }
+
+ // Ignoring, as the stream format model array has already been added.
+ if (formatStreamModelArray != null) {
+ return;
+ }
+
+ // Ignoring, as it is not an original audio track.
+ if (!formatStreamModel.toString().contains(DEFAULT_AUDIO_TRACKS_IDENTIFIER)) {
+ return;
+ }
+
+ // For some reason, when YouTube handles formatStreamModelArray,
+ // it uses an array with duplicate values at the first and second indices.
+ formatStreamModelArray = new ArrayList<>();
+ formatStreamModelArray.add(formatStreamModel);
+ formatStreamModelArray.add(formatStreamModel);
+ }
+
+ /**
+ * Returns an array of stream format models containing the default audio tracks.
+ *
+ * @param localizedFormatStreamModelArray stream format model array consisting of audio tracks in the system's language.
+ * @return stream format model array consisting of original audio tracks.
+ */
+ public static ArrayList getFormatStreamModelArray(final ArrayList localizedFormatStreamModelArray) {
+ if (!Settings.DISABLE_AUTO_AUDIO_TRACKS.get()) {
+ return localizedFormatStreamModelArray;
+ }
+
+ // Ignoring, as the stream format model array is empty.
+ if (formatStreamModelArray == null || formatStreamModelArray.isEmpty()) {
+ return localizedFormatStreamModelArray;
+ }
+
+ // Initialize the array before returning it.
+ ArrayList defaultFormatStreamModelArray = formatStreamModelArray;
+ formatStreamModelArray = null;
+ return defaultFormatStreamModelArray;
+ }
+
+ // endregion
+
+ // region [Disable splash animation] patch
+
+ public static boolean disableSplashAnimation(boolean original) {
+ try {
+ return !Settings.DISABLE_SPLASH_ANIMATION.get() && original;
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to load disableSplashAnimation", ex);
+ }
+ return original;
+ }
+
+ // endregion
+
+ // region [Enable gradient loading screen] patch
+
+ public static boolean enableGradientLoadingScreen() {
+ return Settings.ENABLE_GRADIENT_LOADING_SCREEN.get();
+ }
+
+ // endregion
+
+ // region [Hide layout components] patch
+
+ private static String[] accountMenuBlockList;
+
+ static {
+ accountMenuBlockList = Settings.HIDE_ACCOUNT_MENU_FILTER_STRINGS.get().split("\\n");
+ // Some settings should not be hidden.
+ accountMenuBlockList = Arrays.stream(accountMenuBlockList)
+ .filter(item -> !Objects.equals(item, str("settings")))
+ .toArray(String[]::new);
+ }
+
+ /**
+ * hide account menu in you tab
+ *
+ * @param menuTitleCharSequence menu title
+ */
+ public static void hideAccountList(View view, CharSequence menuTitleCharSequence) {
+ if (!Settings.HIDE_ACCOUNT_MENU.get())
+ return;
+ if (menuTitleCharSequence == null)
+ return;
+ if (!(view.getParent().getParent().getParent() instanceof ViewGroup viewGroup))
+ return;
+
+ hideAccountMenu(viewGroup, menuTitleCharSequence.toString());
+ }
+
+ /**
+ * hide account menu for tablet and old clients
+ *
+ * @param menuTitleCharSequence menu title
+ */
+ public static void hideAccountMenu(View view, CharSequence menuTitleCharSequence) {
+ if (!Settings.HIDE_ACCOUNT_MENU.get())
+ return;
+ if (menuTitleCharSequence == null)
+ return;
+ if (!(view.getParent().getParent() instanceof ViewGroup viewGroup))
+ return;
+
+ hideAccountMenu(viewGroup, menuTitleCharSequence.toString());
+ }
+
+ private static void hideAccountMenu(ViewGroup viewGroup, String menuTitleString) {
+ for (String filter : accountMenuBlockList) {
+ if (!filter.isEmpty() && menuTitleString.equals(filter)) {
+ if (viewGroup.getLayoutParams() instanceof MarginLayoutParams)
+ hideViewGroupByMarginLayoutParams(viewGroup);
+ else
+ viewGroup.setLayoutParams(new LayoutParams(0, 0));
+ }
+ }
+ }
+
+ public static int hideHandle(int originalValue) {
+ return Settings.HIDE_HANDLE.get() ? 8 : originalValue;
+ }
+
+ public static boolean hideFloatingMicrophone(boolean original) {
+ return Settings.HIDE_FLOATING_MICROPHONE.get() || original;
+ }
+
+ public static boolean hideSnackBar() {
+ return Settings.HIDE_SNACK_BAR.get();
+ }
+
+ // endregion
+
+ // region [Hide navigation bar components] patch
+
+ private static final Map shouldHideMap = new EnumMap<>(NavigationButton.class) {
+ {
+ put(NavigationButton.HOME, Settings.HIDE_NAVIGATION_HOME_BUTTON.get());
+ put(NavigationButton.SHORTS, Settings.HIDE_NAVIGATION_SHORTS_BUTTON.get());
+ put(NavigationButton.SUBSCRIPTIONS, Settings.HIDE_NAVIGATION_SUBSCRIPTIONS_BUTTON.get());
+ put(NavigationButton.CREATE, Settings.HIDE_NAVIGATION_CREATE_BUTTON.get());
+ put(NavigationButton.NOTIFICATIONS, Settings.HIDE_NAVIGATION_NOTIFICATIONS_BUTTON.get());
+ put(NavigationButton.LIBRARY, Settings.HIDE_NAVIGATION_LIBRARY_BUTTON.get());
+ }
+ };
+
+ public static boolean enableNarrowNavigationButton(boolean original) {
+ return Settings.ENABLE_NARROW_NAVIGATION_BUTTONS.get() || original;
+ }
+
+ public static boolean enableTranslucentNavigationBar() {
+ return Settings.ENABLE_TRANSLUCENT_NAVIGATION_BAR.get();
+ }
+
+ public static boolean switchCreateWithNotificationButton(boolean original) {
+ return Settings.SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON.get() || original;
+ }
+
+ public static void navigationTabCreated(NavigationButton button, View tabView) {
+ if (BooleanUtils.isTrue(shouldHideMap.get(button))) {
+ tabView.setVisibility(View.GONE);
+ }
+ }
+
+ public static void hideNavigationLabel(TextView view) {
+ hideViewUnderCondition(Settings.HIDE_NAVIGATION_LABEL.get(), view);
+ }
+
+ public static void hideNavigationBar(View view) {
+ hideViewUnderCondition(Settings.HIDE_NAVIGATION_BAR.get(), view);
+ }
+
+ // endregion
+
+ // region [Remove viewer discretion dialog] patch
+
+ /**
+ * Injection point.
+ *
+ * The {@link AlertDialog#getButton(int)} method must be used after {@link AlertDialog#show()} is called.
+ * Otherwise {@link AlertDialog#getButton(int)} method will always return null.
+ *
+ *
+ * That's why {@link AlertDialog#show()} is absolutely necessary.
+ * Instead, use two tricks to hide Alertdialog.
+ *
+ * 1. Change the size of AlertDialog to 0.
+ * 2. Disable AlertDialog's background dim.
+ *
+ * This way, AlertDialog will be completely hidden,
+ * and {@link AlertDialog#getButton(int)} method can be used without issue.
+ */
+ public static void confirmDialog(final AlertDialog dialog) {
+ if (!Settings.REMOVE_VIEWER_DISCRETION_DIALOG.get()) {
+ return;
+ }
+
+ // This method is called after AlertDialog#show(),
+ // So we need to hide the AlertDialog before pressing the possitive button.
+ final Window window = dialog.getWindow();
+ final Button button = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
+ if (window != null && button != null) {
+ WindowManager.LayoutParams params = window.getAttributes();
+ params.height = 0;
+ params.width = 0;
+
+ // Change the size of AlertDialog to 0.
+ window.setAttributes(params);
+
+ // Disable AlertDialog's background dim.
+ window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
+ Utils.clickView(button);
+ }
+ }
+
+ public static void confirmDialogAgeVerified(final AlertDialog dialog) {
+ final Button button = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
+ if (!button.getText().toString().equals(str("og_continue")))
+ return;
+
+ confirmDialog(dialog);
+ }
+
+ // endregion
+
+ // region [Spoof app version] patch
+
+ public static String getVersionOverride(String appVersion) {
+ return Settings.SPOOF_APP_VERSION.get()
+ ? Settings.SPOOF_APP_VERSION_TARGET.get()
+ : appVersion;
+ }
+
+ // endregion
+
+ // region [Toolbar components] patch
+
+ private static final int generalHeaderAttributeId = ResourceUtils.getAttrIdentifier("ytWordmarkHeader");
+ private static final int premiumHeaderAttributeId = ResourceUtils.getAttrIdentifier("ytPremiumWordmarkHeader");
+
+ public static void setDrawerNavigationHeader(View lithoView) {
+ final int headerAttributeId = getHeaderAttributeId();
+
+ lithoView.getViewTreeObserver().addOnDrawListener(() -> {
+ if (!(lithoView instanceof ViewGroup viewGroup))
+ return;
+ if (!(viewGroup.getChildAt(0) instanceof ImageView imageView))
+ return;
+ final Activity mActivity = Utils.getActivity();
+ if (mActivity == null)
+ return;
+ imageView.setImageDrawable(getHeaderDrawable(mActivity, headerAttributeId));
+ });
+ }
+
+ public static int getHeaderAttributeId() {
+ return Settings.CHANGE_YOUTUBE_HEADER.get()
+ ? premiumHeaderAttributeId
+ : generalHeaderAttributeId;
+ }
+
+ public static boolean overridePremiumHeader() {
+ return Settings.CHANGE_YOUTUBE_HEADER.get();
+ }
+
+ private static Drawable getHeaderDrawable(Activity mActivity, int resourceId) {
+ // Rest of the implementation added by patch.
+ return ResourceUtils.getDrawable("");
+ }
+
+ private static final int searchBarId = ResourceUtils.getIdIdentifier("search_bar");
+ private static final int youtubeTextId = ResourceUtils.getIdIdentifier("youtube_text");
+ private static final int searchBoxId = ResourceUtils.getIdIdentifier("search_box");
+ private static final int searchIconId = ResourceUtils.getIdIdentifier("search_icon");
+
+ private static final boolean wideSearchbarEnabled = Settings.ENABLE_WIDE_SEARCH_BAR.get();
+ // Loads the search bar deprecated by Google.
+ private static final boolean wideSearchbarWithHeaderEnabled = Settings.ENABLE_WIDE_SEARCH_BAR_WITH_HEADER.get();
+ private static final boolean wideSearchbarYouTabEnabled = Settings.ENABLE_WIDE_SEARCH_BAR_IN_YOU_TAB.get();
+
+ public static boolean enableWideSearchBar(boolean original) {
+ return wideSearchbarEnabled || original;
+ }
+
+ /**
+ * Limitation: Premium header will not be applied for YouTube Premium users if the user uses the 'Wide search bar with header' option.
+ * This is because it forces the deprecated search bar to be loaded.
+ * As a solution to this limitation, 'Change YouTube header' patch is required.
+ */
+ public static boolean enableWideSearchBarWithHeader(boolean original) {
+ if (!wideSearchbarEnabled)
+ return original;
+ else
+ return wideSearchbarWithHeaderEnabled || original;
+ }
+
+ public static boolean enableWideSearchBarWithHeaderInverse(boolean original) {
+ if (!wideSearchbarEnabled)
+ return original;
+ else
+ return !wideSearchbarWithHeaderEnabled && original;
+ }
+
+ public static boolean enableWideSearchBarInYouTab(boolean original) {
+ if (!wideSearchbarEnabled)
+ return original;
+ else
+ return !wideSearchbarYouTabEnabled && original;
+ }
+
+ public static void setWideSearchBarLayout(View view) {
+ if (!wideSearchbarEnabled)
+ return;
+ if (!(view.findViewById(searchBarId) instanceof RelativeLayout searchBarView))
+ return;
+
+ // When the deprecated search bar is loaded, two search bars overlap.
+ // Manually hides another search bar.
+ if (wideSearchbarWithHeaderEnabled) {
+ final View searchIconView = searchBarView.findViewById(searchIconId);
+ final View searchBoxView = searchBarView.findViewById(searchBoxId);
+ final View textView = searchBarView.findViewById(youtubeTextId);
+ if (textView != null) {
+ RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(0, 0);
+ layoutParams.setMargins(0, 0, 0, 0);
+ textView.setLayoutParams(layoutParams);
+ }
+ // The search icon in the deprecated search bar is clickable, but onClickListener is not assigned.
+ // Assign onClickListener and disable the effect when clicked.
+ if (searchIconView != null && searchBoxView != null) {
+ searchIconView.setOnClickListener(view1 -> searchBoxView.callOnClick());
+ searchIconView.getBackground().setAlpha(0);
+ }
+ } else {
+ // This is the legacy method - Wide search bar without YouTube header.
+ // Since the padding start is 0, it does not look good.
+ // Add a padding start of 8.0 dip.
+ final int paddingLeft = searchBarView.getPaddingLeft();
+ final int paddingRight = searchBarView.getPaddingRight();
+ final int paddingTop = searchBarView.getPaddingTop();
+ final int paddingBottom = searchBarView.getPaddingBottom();
+ final int paddingStart = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8f, Utils.getResources().getDisplayMetrics());
+
+ // In RelativeLayout, paddingStart cannot be assigned programmatically.
+ // Check RTL layout and set left padding or right padding.
+ if (Utils.isRightToLeftTextLayout()) {
+ searchBarView.setPadding(paddingLeft, paddingTop, paddingStart, paddingBottom);
+ } else {
+ searchBarView.setPadding(paddingStart, paddingTop, paddingRight, paddingBottom);
+ }
+ }
+ }
+
+ public static boolean hideCastButton(boolean original) {
+ return !Settings.HIDE_TOOLBAR_CAST_BUTTON.get() && original;
+ }
+
+ public static void hideCastButton(MenuItem menuItem) {
+ if (!Settings.HIDE_TOOLBAR_CAST_BUTTON.get())
+ return;
+
+ menuItem.setVisible(false);
+ menuItem.setEnabled(false);
+ }
+
+ public static void hideCreateButton(String enumString, View view) {
+ if (!Settings.HIDE_TOOLBAR_CREATE_BUTTON.get())
+ return;
+
+ hideViewUnderCondition(isCreateButton(enumString), view);
+ }
+
+ public static void hideNotificationButton(String enumString, View view) {
+ if (!Settings.HIDE_TOOLBAR_NOTIFICATION_BUTTON.get())
+ return;
+
+ hideViewUnderCondition(isNotificationButton(enumString), view);
+ }
+
+ public static boolean hideSearchTermThumbnail() {
+ return Settings.HIDE_SEARCH_TERM_THUMBNAIL.get();
+ }
+
+ private static final boolean hideImageSearchButton = Settings.HIDE_IMAGE_SEARCH_BUTTON.get();
+ private static final boolean hideVoiceSearchButton = Settings.HIDE_VOICE_SEARCH_BUTTON.get();
+
+ /**
+ * If the user does not hide the Image search button but only the Voice search button,
+ * {@link View#setVisibility(int)} cannot be used on the Voice search button.
+ * (This breaks the search bar layout.)
+ *
+ * In this case, {@link Utils#hideViewByLayoutParams(View)} should be used.
+ */
+ private static final boolean showImageSearchButtonAndHideVoiceSearchButton = !hideImageSearchButton && hideVoiceSearchButton && ImageSearchButton();
+
+ public static boolean hideImageSearchButton(boolean original) {
+ return !hideImageSearchButton && original;
+ }
+
+ public static void hideVoiceSearchButton(View view) {
+ if (showImageSearchButtonAndHideVoiceSearchButton) {
+ hideViewByLayoutParams(view);
+ } else {
+ hideViewUnderCondition(hideVoiceSearchButton, view);
+ }
+ }
+
+ public static void hideVoiceSearchButton(View view, int visibility) {
+ if (showImageSearchButtonAndHideVoiceSearchButton) {
+ view.setVisibility(visibility);
+ hideViewByLayoutParams(view);
+ } else {
+ view.setVisibility(
+ hideVoiceSearchButton
+ ? View.GONE : visibility
+ );
+ }
+ }
+
+ /**
+ * In ReVanced, image files are replaced to change the header,
+ * Whereas in RVX, the header is changed programmatically.
+ * There is an issue where the header is not changed in RVX when YouTube Doodles are hidden.
+ * As a workaround, manually set the header when YouTube Doodles are hidden.
+ */
+ public static void hideYouTubeDoodles(ImageView imageView, Drawable drawable) {
+ final Activity mActivity = Utils.getActivity();
+ if (Settings.HIDE_YOUTUBE_DOODLES.get() && mActivity != null) {
+ drawable = getHeaderDrawable(mActivity, getHeaderAttributeId());
+ }
+ imageView.setImageDrawable(drawable);
+ }
+
+ private static final int settingsDrawableId =
+ ResourceUtils.getDrawableIdentifier("yt_outline_gear_black_24");
+
+ public static int getCreateButtonDrawableId(int original) {
+ return Settings.REPLACE_TOOLBAR_CREATE_BUTTON.get() &&
+ settingsDrawableId != 0
+ ? settingsDrawableId
+ : original;
+ }
+
+ public static void replaceCreateButton(String enumString, View toolbarView) {
+ if (!Settings.REPLACE_TOOLBAR_CREATE_BUTTON.get())
+ return;
+ // Check if the button is a create button.
+ if (!isCreateButton(enumString))
+ return;
+ ImageView imageView = getChildView((ViewGroup) toolbarView, view -> view instanceof ImageView);
+ if (imageView == null)
+ return;
+
+ // Overriding is possible only after OnClickListener is assigned to the create button.
+ Utils.runOnMainThreadDelayed(() -> {
+ if (Settings.REPLACE_TOOLBAR_CREATE_BUTTON_TYPE.get()) {
+ imageView.setOnClickListener(GeneralPatch::openRVXSettings);
+ imageView.setOnLongClickListener(button -> {
+ openYouTubeSettings(button);
+ return true;
+ });
+ } else {
+ imageView.setOnClickListener(GeneralPatch::openYouTubeSettings);
+ imageView.setOnLongClickListener(button -> {
+ openRVXSettings(button);
+ return true;
+ });
+ }
+ }, 0);
+ }
+
+ private static void openYouTubeSettings(View view) {
+ Context context = view.getContext();
+ Intent intent = new Intent(Intent.ACTION_MAIN);
+ intent.setPackage(context.getPackageName());
+ intent.setClass(context, Shell_SettingsActivity.class);
+ intent.setFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
+ context.startActivity(intent);
+ }
+
+ private static void openRVXSettings(View view) {
+ Context context = view.getContext();
+ Intent intent = new Intent(Intent.ACTION_MAIN);
+ intent.setPackage(context.getPackageName());
+ intent.setData(Uri.parse("revanced_extended_settings_intent"));
+ intent.setClass(context, VideoQualitySettingsActivity.class);
+ context.startActivity(intent);
+ }
+
+ /**
+ * The theme of {@link Shell_SettingsActivity} is dark theme.
+ * Since this theme is hardcoded, we should manually specify the theme for the activity.
+ *
+ * Since {@link Shell_SettingsActivity} only invokes {@link SettingsActivity}, finish activity after specifying a theme.
+ *
+ * @param base {@link Shell_SettingsActivity}
+ */
+ public static void setShellActivityTheme(Activity base) {
+ if (!Settings.REPLACE_TOOLBAR_CREATE_BUTTON.get())
+ return;
+
+ base.setTheme(ThemeUtils.getThemeId());
+ Utils.runOnMainThreadDelayed(base::finish, 0);
+ }
+
+
+ private static boolean isCreateButton(String enumString) {
+ return StringUtils.equalsAny(
+ enumString,
+ "CREATION_ENTRY", // Create button for Phone layout
+ "FAB_CAMERA" // Create button for Tablet layout
+ );
+ }
+
+ private static boolean isNotificationButton(String enumString) {
+ return StringUtils.equalsAny(
+ enumString,
+ "TAB_ACTIVITY", // Notification button
+ "TAB_ACTIVITY_CAIRO" // Notification button (new layout)
+ );
+ }
+
+ // endregion
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/LayoutSwitchPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/LayoutSwitchPatch.java
new file mode 100644
index 0000000000..56d3430803
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/LayoutSwitchPatch.java
@@ -0,0 +1,79 @@
+package app.revanced.extension.youtube.patches.general;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.BooleanUtils;
+
+import java.util.Objects;
+
+import app.revanced.extension.shared.utils.PackageUtils;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class LayoutSwitchPatch {
+
+ public enum FormFactor {
+ /**
+ * Unmodified type, and same as un-patched.
+ */
+ ORIGINAL(null, null, null),
+ SMALL_FORM_FACTOR(1, null, TRUE),
+ SMALL_FORM_FACTOR_WIDTH_DP(1, 480, TRUE),
+ LARGE_FORM_FACTOR(2, null, FALSE),
+ LARGE_FORM_FACTOR_WIDTH_DP(2, 600, FALSE);
+
+ @Nullable
+ final Integer formFactorType;
+
+ @Nullable
+ final Integer widthDp;
+
+ @Nullable
+ final Boolean setMinimumDp;
+
+ FormFactor(@Nullable Integer formFactorType, @Nullable Integer widthDp, @Nullable Boolean setMinimumDp) {
+ this.formFactorType = formFactorType;
+ this.widthDp = widthDp;
+ this.setMinimumDp = setMinimumDp;
+ }
+
+ private boolean setMinimumDp() {
+ return BooleanUtils.isTrue(setMinimumDp);
+ }
+ }
+
+ private static final FormFactor FORM_FACTOR = Settings.CHANGE_LAYOUT.get();
+
+ public static int getFormFactor(int original) {
+ Integer formFactorType = FORM_FACTOR.formFactorType;
+ return formFactorType == null
+ ? original
+ : formFactorType;
+ }
+
+ public static int getWidthDp(int original) {
+ Integer widthDp = FORM_FACTOR.widthDp;
+ if (widthDp == null) {
+ return original;
+ }
+ final int smallestScreenWidthDp = PackageUtils.getSmallestScreenWidthDp();
+ if (smallestScreenWidthDp == 0) {
+ return original;
+ }
+ return FORM_FACTOR.setMinimumDp()
+ ? Math.min(smallestScreenWidthDp, widthDp)
+ : Math.max(smallestScreenWidthDp, widthDp);
+ }
+
+ public static boolean phoneLayoutEnabled() {
+ return Objects.equals(FORM_FACTOR.formFactorType, 1);
+ }
+
+ public static boolean tabletLayoutEnabled() {
+ return Objects.equals(FORM_FACTOR.formFactorType, 2);
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/MiniplayerPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/MiniplayerPatch.java
new file mode 100644
index 0000000000..9c13d5a52e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/MiniplayerPatch.java
@@ -0,0 +1,197 @@
+package app.revanced.extension.youtube.patches.general;
+
+import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_1;
+import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_2;
+import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_3;
+import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.ORIGINAL;
+import static app.revanced.extension.youtube.utils.ExtendedUtils.validateValue;
+
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+
+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.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class MiniplayerPatch {
+
+ /**
+ * Mini player type. Null fields indicates to use the original un-patched value.
+ */
+ public enum MiniplayerType {
+ /**
+ * Unmodified type, and same as un-patched.
+ */
+ ORIGINAL(null, null),
+ PHONE(false, null),
+ TABLET(true, null),
+ MODERN_1(null, 1),
+ MODERN_2(null, 2),
+ MODERN_3(null, 3);
+
+ /**
+ * Legacy tablet hook value.
+ */
+ @Nullable
+ final Boolean legacyTabletOverride;
+
+ /**
+ * Modern player type used by YT.
+ */
+ @Nullable
+ final Integer modernPlayerType;
+
+ MiniplayerType(@Nullable Boolean legacyTabletOverride, @Nullable Integer modernPlayerType) {
+ this.legacyTabletOverride = legacyTabletOverride;
+ this.modernPlayerType = modernPlayerType;
+ }
+
+ public boolean isModern() {
+ return modernPlayerType != null;
+ }
+ }
+
+ /**
+ * Modern subtitle overlay for {@link MiniplayerType#MODERN_2}.
+ * Resource is not present in older targets, and this field will be zero.
+ */
+ private static final int MODERN_OVERLAY_SUBTITLE_TEXT
+ = ResourceUtils.getIdIdentifier("modern_miniplayer_subtitle_text");
+
+ private static final MiniplayerType CURRENT_TYPE = Settings.MINIPLAYER_TYPE.get();
+
+ private static final boolean DOUBLE_TAP_ACTION_ENABLED =
+ (CURRENT_TYPE == MODERN_1 || CURRENT_TYPE == MODERN_2 || CURRENT_TYPE == MODERN_3) && Settings.MINIPLAYER_DOUBLE_TAP_ACTION.get();
+
+ private static final boolean DRAG_AND_DROP_ENABLED =
+ CURRENT_TYPE == MODERN_1 && Settings.MINIPLAYER_DRAG_AND_DROP.get();
+
+ private static final boolean HIDE_EXPAND_CLOSE_AVAILABLE =
+ (CURRENT_TYPE == MODERN_1 || CURRENT_TYPE == MODERN_3) &&
+ !DOUBLE_TAP_ACTION_ENABLED &&
+ !DRAG_AND_DROP_ENABLED;
+
+ private static final boolean HIDE_EXPAND_CLOSE_ENABLED =
+ HIDE_EXPAND_CLOSE_AVAILABLE && Settings.MINIPLAYER_HIDE_EXPAND_CLOSE.get();
+
+ private static final boolean HIDE_SUBTEXT_ENABLED =
+ (CURRENT_TYPE == MODERN_1 || CURRENT_TYPE == MODERN_3) && Settings.MINIPLAYER_HIDE_SUBTEXT.get();
+
+ private static final boolean HIDE_REWIND_FORWARD_ENABLED =
+ CURRENT_TYPE == MODERN_1 && Settings.MINIPLAYER_HIDE_REWIND_FORWARD.get();
+
+ private static final int OPACITY_LEVEL;
+
+ static {
+ final int opacity = validateValue(
+ Settings.MINIPLAYER_OPACITY,
+ 0,
+ 100,
+ "revanced_miniplayer_opacity_invalid_toast"
+ );
+
+ OPACITY_LEVEL = (opacity * 255) / 100;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean getLegacyTabletMiniplayerOverride(boolean original) {
+ Boolean isTablet = CURRENT_TYPE.legacyTabletOverride;
+ return isTablet == null
+ ? original
+ : isTablet;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean getModernMiniplayerOverride(boolean original) {
+ return CURRENT_TYPE == ORIGINAL
+ ? original
+ : CURRENT_TYPE.isModern();
+ }
+
+ /**
+ * Injection point.
+ */
+ public static int getModernMiniplayerOverrideType(int original) {
+ Integer modernValue = CURRENT_TYPE.modernPlayerType;
+ return modernValue == null
+ ? original
+ : modernValue;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void adjustMiniplayerOpacity(ImageView view) {
+ if (CURRENT_TYPE == MODERN_1) {
+ view.setImageAlpha(OPACITY_LEVEL);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean enableMiniplayerDoubleTapAction() {
+ return DOUBLE_TAP_ACTION_ENABLED;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean enableMiniplayerDragAndDrop() {
+ return DRAG_AND_DROP_ENABLED;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void hideMiniplayerExpandClose(ImageView view) {
+ Utils.hideViewByRemovingFromParentUnderCondition(HIDE_EXPAND_CLOSE_ENABLED, view);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void hideMiniplayerRewindForward(ImageView view) {
+ Utils.hideViewByRemovingFromParentUnderCondition(HIDE_REWIND_FORWARD_ENABLED, view);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean hideMiniplayerSubTexts(View view) {
+ // Different subviews are passed in, but only TextView and layouts are of interest here.
+ final boolean hideView = HIDE_SUBTEXT_ENABLED && (view instanceof TextView || view instanceof LinearLayout);
+ Utils.hideViewByRemovingFromParentUnderCondition(hideView, view);
+ return hideView || view == null;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void playerOverlayGroupCreated(View group) {
+ // Modern 2 has an half broken subtitle that is always present.
+ // Always hide it to make the miniplayer mostly usable.
+ if (CURRENT_TYPE == MODERN_2 && MODERN_OVERLAY_SUBTITLE_TEXT != 0) {
+ if (group instanceof ViewGroup viewGroup) {
+ View subtitleText = Utils.getChildView(viewGroup, true,
+ view -> view.getId() == MODERN_OVERLAY_SUBTITLE_TEXT);
+
+ if (subtitleText != null) {
+ subtitleText.setVisibility(View.GONE);
+ Logger.printDebug(() -> "Modern overlay subtitle view set to hidden");
+ }
+ }
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/SettingsMenuPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/SettingsMenuPatch.java
new file mode 100644
index 0000000000..792fe46350
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/SettingsMenuPatch.java
@@ -0,0 +1,52 @@
+package app.revanced.extension.youtube.patches.general;
+
+import androidx.preference.PreferenceScreen;
+
+import app.revanced.extension.shared.patches.BaseSettingsMenuPatch;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class SettingsMenuPatch extends BaseSettingsMenuPatch {
+
+ public static void hideSettingsMenu(PreferenceScreen mPreferenceScreen) {
+ if (mPreferenceScreen == null) return;
+ for (SettingsMenuComponent component : SettingsMenuComponent.values())
+ if (component.enabled)
+ removePreference(mPreferenceScreen, component.key);
+ }
+
+ private enum SettingsMenuComponent {
+ YOUTUBE_TV("yt_unplugged_pref_key", Settings.HIDE_SETTINGS_MENU_YOUTUBE_TV.get()),
+ PARENT_TOOLS("parent_tools_key", Settings.HIDE_SETTINGS_MENU_PARENT_TOOLS.get()),
+ PRE_PURCHASE("yt_unlimited_pre_purchase_key", Settings.HIDE_SETTINGS_MENU_PRE_PURCHASE.get()),
+ GENERAL("general_key", Settings.HIDE_SETTINGS_MENU_GENERAL.get()),
+ ACCOUNT("account_switcher_key", Settings.HIDE_SETTINGS_MENU_ACCOUNT.get()),
+ DATA_SAVING("data_saving_settings_key", Settings.HIDE_SETTINGS_MENU_DATA_SAVING.get()),
+ AUTOPLAY("auto_play_key", Settings.HIDE_SETTINGS_MENU_AUTOPLAY.get()),
+ VIDEO_QUALITY_PREFERENCES("video_quality_settings_key", Settings.HIDE_SETTINGS_MENU_VIDEO_QUALITY_PREFERENCES.get()),
+ POST_PURCHASE("yt_unlimited_post_purchase_key", Settings.HIDE_SETTINGS_MENU_POST_PURCHASE.get()),
+ OFFLINE("offline_key", Settings.HIDE_SETTINGS_MENU_OFFLINE.get()),
+ WATCH_ON_TV("pair_with_tv_key", Settings.HIDE_SETTINGS_MENU_WATCH_ON_TV.get()),
+ MANAGE_ALL_HISTORY("history_key", Settings.HIDE_SETTINGS_MENU_MANAGE_ALL_HISTORY.get()),
+ YOUR_DATA_IN_YOUTUBE("your_data_key", Settings.HIDE_SETTINGS_MENU_YOUR_DATA_IN_YOUTUBE.get()),
+ PRIVACY("privacy_key", Settings.HIDE_SETTINGS_MENU_PRIVACY.get()),
+ TRY_EXPERIMENTAL_NEW_FEATURES("premium_early_access_browse_page_key", Settings.HIDE_SETTINGS_MENU_TRY_EXPERIMENTAL_NEW_FEATURES.get()),
+ PURCHASES_AND_MEMBERSHIPS("subscription_product_setting_key", Settings.HIDE_SETTINGS_MENU_PURCHASES_AND_MEMBERSHIPS.get()),
+ BILLING_AND_PAYMENTS("billing_and_payment_key", Settings.HIDE_SETTINGS_MENU_BILLING_AND_PAYMENTS.get()),
+ NOTIFICATIONS("notification_key", Settings.HIDE_SETTINGS_MENU_NOTIFICATIONS.get()),
+ THIRD_PARTY("third_party_key", Settings.HIDE_SETTINGS_MENU_THIRD_PARTY.get()),
+ CONNECTED_APPS("connected_accounts_browse_page_key", Settings.HIDE_SETTINGS_MENU_CONNECTED_APPS.get()),
+ LIVE_CHAT("live_chat_key", Settings.HIDE_SETTINGS_MENU_LIVE_CHAT.get()),
+ CAPTIONS("captions_key", Settings.HIDE_SETTINGS_MENU_CAPTIONS.get()),
+ ACCESSIBILITY("accessibility_settings_key", Settings.HIDE_SETTINGS_MENU_ACCESSIBILITY.get()),
+ ABOUT("about_key", Settings.HIDE_SETTINGS_MENU_ABOUT.get());
+
+ private final String key;
+ private final boolean enabled;
+
+ SettingsMenuComponent(String key, boolean enabled) {
+ this.key = key;
+ this.enabled = enabled;
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/YouTubeMusicActionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/YouTubeMusicActionsPatch.java
new file mode 100644
index 0000000000..52c0da246a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/general/YouTubeMusicActionsPatch.java
@@ -0,0 +1,55 @@
+package app.revanced.extension.youtube.patches.general;
+
+import androidx.annotation.NonNull;
+
+import org.apache.commons.lang3.StringUtils;
+
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.utils.ExtendedUtils;
+import app.revanced.extension.youtube.utils.VideoUtils;
+
+@SuppressWarnings("unused")
+public final class YouTubeMusicActionsPatch extends VideoUtils {
+
+ private static final String PACKAGE_NAME_YOUTUBE_MUSIC = "com.google.android.apps.youtube.music";
+
+ private static final boolean isOverrideYouTubeMusicEnabled =
+ Settings.OVERRIDE_YOUTUBE_MUSIC_BUTTON.get();
+
+ private static final boolean overrideYouTubeMusicEnabled =
+ isOverrideYouTubeMusicEnabled && isYouTubeMusicEnabled();
+
+ public static String overridePackageName(@NonNull String packageName) {
+ if (!overrideYouTubeMusicEnabled) {
+ return packageName;
+ }
+ if (!StringUtils.equals(PACKAGE_NAME_YOUTUBE_MUSIC, packageName)) {
+ return packageName;
+ }
+ final String thirdPartyPackageName = Settings.THIRD_PARTY_YOUTUBE_MUSIC_PACKAGE_NAME.get();
+ if (!ExtendedUtils.isPackageEnabled(thirdPartyPackageName)) {
+ return packageName;
+ }
+ return thirdPartyPackageName;
+ }
+
+ private static boolean isYouTubeMusicEnabled() {
+ return ExtendedUtils.isPackageEnabled(PACKAGE_NAME_YOUTUBE_MUSIC);
+ }
+
+ public static final class HookYouTubeMusicAvailability implements Setting.Availability {
+ @Override
+ public boolean isAvailable() {
+ return isYouTubeMusicEnabled();
+ }
+ }
+
+ public static final class HookYouTubeMusicPackageNameAvailability implements Setting.Availability {
+ @Override
+ public boolean isAvailable() {
+ return isOverrideYouTubeMusicEnabled && isYouTubeMusicEnabled();
+ }
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/BackgroundPlaybackPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/BackgroundPlaybackPatch.java
new file mode 100644
index 0000000000..4cf3456ee9
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/BackgroundPlaybackPatch.java
@@ -0,0 +1,12 @@
+package app.revanced.extension.youtube.patches.misc;
+
+import app.revanced.extension.youtube.shared.ShortsPlayerState;
+
+@SuppressWarnings("unused")
+public class BackgroundPlaybackPatch {
+
+ public static boolean allowBackgroundPlayback(boolean original) {
+ return original || ShortsPlayerState.getCurrent().isClosed();
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/ExternalBrowserPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/ExternalBrowserPatch.java
new file mode 100644
index 0000000000..794fd93e0c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/ExternalBrowserPatch.java
@@ -0,0 +1,14 @@
+package app.revanced.extension.youtube.patches.misc;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class ExternalBrowserPatch {
+
+ public static String enableExternalBrowser(final String original) {
+ if (!Settings.ENABLE_EXTERNAL_BROWSER.get())
+ return original;
+
+ return "";
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/OpenLinksDirectlyPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/OpenLinksDirectlyPatch.java
new file mode 100644
index 0000000000..a3e9b9658e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/OpenLinksDirectlyPatch.java
@@ -0,0 +1,24 @@
+package app.revanced.extension.youtube.patches.misc;
+
+import android.net.Uri;
+
+import java.util.Objects;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class OpenLinksDirectlyPatch {
+ private static final String YOUTUBE_REDIRECT_PATH = "/redirect";
+
+ public static Uri enableBypassRedirect(String uri) {
+ final Uri parsed = Uri.parse(uri);
+ if (!Settings.ENABLE_OPEN_LINKS_DIRECTLY.get())
+ return parsed;
+
+ if (Objects.equals(parsed.getPath(), YOUTUBE_REDIRECT_PATH)) {
+ return Uri.parse(Uri.decode(parsed.getQueryParameter("q")));
+ }
+
+ return parsed;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/OpusCodecPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/OpusCodecPatch.java
new file mode 100644
index 0000000000..32696151a0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/OpusCodecPatch.java
@@ -0,0 +1,11 @@
+package app.revanced.extension.youtube.patches.misc;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class OpusCodecPatch {
+
+ public static boolean enableOpusCodec() {
+ return Settings.ENABLE_OPUS_CODEC.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/QUICProtocolPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/QUICProtocolPatch.java
new file mode 100644
index 0000000000..b8e099b915
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/QUICProtocolPatch.java
@@ -0,0 +1,17 @@
+package app.revanced.extension.youtube.patches.misc;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class QUICProtocolPatch {
+
+ public static boolean disableQUICProtocol(boolean original) {
+ try {
+ return !Settings.DISABLE_QUIC_PROTOCOL.get() && original;
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to load disableQUICProtocol", ex);
+ }
+ return original;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/ShareSheetPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/ShareSheetPatch.java
new file mode 100644
index 0000000000..a1236f4797
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/ShareSheetPatch.java
@@ -0,0 +1,62 @@
+package app.revanced.extension.youtube.patches.misc;
+
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.view.ViewGroup;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.components.ShareSheetMenuFilter;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class ShareSheetPatch {
+ private static final boolean changeShareSheetEnabled = Settings.CHANGE_SHARE_SHEET.get();
+
+ private static void clickSystemShareButton(final RecyclerView bottomSheetRecyclerView,
+ final RecyclerView appsContainerRecyclerView) {
+ if (appsContainerRecyclerView.getChildAt(appsContainerRecyclerView.getChildCount() - 1) instanceof ViewGroup parentView &&
+ parentView.getChildAt(0) instanceof ViewGroup shareWithOtherAppsView) {
+ ShareSheetMenuFilter.isShareSheetMenuVisible = false;
+
+ bottomSheetRecyclerView.setVisibility(View.GONE);
+ Utils.clickView(shareWithOtherAppsView);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void onShareSheetMenuCreate(final RecyclerView recyclerView) {
+ if (!changeShareSheetEnabled)
+ return;
+
+ recyclerView.getViewTreeObserver().addOnDrawListener(() -> {
+ try {
+ if (!ShareSheetMenuFilter.isShareSheetMenuVisible) {
+ return;
+ }
+ if (!(recyclerView.getChildAt(0) instanceof ViewGroup parentView4th)) {
+ return;
+ }
+ if (parentView4th.getChildAt(0) instanceof ViewGroup parentView3rd &&
+ parentView3rd.getChildAt(0) instanceof RecyclerView appsContainerRecyclerView) {
+ clickSystemShareButton(recyclerView, appsContainerRecyclerView);
+ } else if (parentView4th.getChildAt(1) instanceof ViewGroup parentView3rd &&
+ parentView3rd.getChildAt(0) instanceof RecyclerView appsContainerRecyclerView) {
+ clickSystemShareButton(recyclerView, appsContainerRecyclerView);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onShareSheetMenuCreate failure", ex);
+ }
+ });
+ }
+
+ /**
+ * Injection point.
+ */
+ public static String overridePackageName(String original) {
+ return changeShareSheetEnabled ? "" : original;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/SpoofStreamingDataPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/SpoofStreamingDataPatch.java
new file mode 100644
index 0000000000..f2ed0c18b5
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/SpoofStreamingDataPatch.java
@@ -0,0 +1,185 @@
+package app.revanced.extension.youtube.patches.misc;
+
+import android.net.Uri;
+import android.text.TextUtils;
+
+import androidx.annotation.Nullable;
+
+import java.nio.ByteBuffer;
+import java.util.Map;
+import java.util.Objects;
+
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType;
+import app.revanced.extension.youtube.patches.misc.requests.StreamingDataRequest;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class SpoofStreamingDataPatch {
+ private static final boolean SPOOF_STREAMING_DATA = Settings.SPOOF_STREAMING_DATA.get();
+
+ /**
+ * Any unreachable ip address. Used to intentionally fail requests.
+ */
+ private static final String UNREACHABLE_HOST_URI_STRING = "https://127.0.0.0";
+ private static final Uri UNREACHABLE_HOST_URI = Uri.parse(UNREACHABLE_HOST_URI_STRING);
+
+ /**
+ * Injection point.
+ * Blocks /get_watch requests by returning an unreachable URI.
+ *
+ * @param playerRequestUri The URI of the player request.
+ * @return An unreachable URI if the request is a /get_watch request, otherwise the original URI.
+ */
+ public static Uri blockGetWatchRequest(Uri playerRequestUri) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ String path = playerRequestUri.getPath();
+
+ if (path != null && path.contains("get_watch")) {
+ Logger.printDebug(() -> "Blocking 'get_watch' by returning unreachable uri");
+
+ return UNREACHABLE_HOST_URI;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "blockGetWatchRequest failure", ex);
+ }
+ }
+
+ return playerRequestUri;
+ }
+
+ /**
+ * Injection point.
+ *
+ * Blocks /initplayback requests.
+ */
+ public static String blockInitPlaybackRequest(String originalUrlString) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ var originalUri = Uri.parse(originalUrlString);
+ String path = originalUri.getPath();
+
+ if (path != null && path.contains("initplayback")) {
+ Logger.printDebug(() -> "Blocking 'initplayback' by returning unreachable url");
+
+ return UNREACHABLE_HOST_URI_STRING;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "blockInitPlaybackRequest failure", ex);
+ }
+ }
+
+ return originalUrlString;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean isSpoofingEnabled() {
+ return SPOOF_STREAMING_DATA;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void fetchStreams(String url, Map requestHeaders) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ Uri uri = Uri.parse(url);
+ String path = uri.getPath();
+ // 'heartbeat' has no video id and appears to be only after playback has started.
+ if (path != null && path.contains("player") && !path.contains("heartbeat")) {
+ String videoId = Objects.requireNonNull(uri.getQueryParameter("id"));
+ StreamingDataRequest.fetchRequest(videoId, requestHeaders);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "buildRequest failure", ex);
+ }
+ }
+ }
+
+ /**
+ * Injection point.
+ * Fix playback by replace the streaming data.
+ * Called after {@link #fetchStreams(String, Map)} .
+ */
+ @Nullable
+ public static ByteBuffer getStreamingData(String videoId) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ StreamingDataRequest request = StreamingDataRequest.getRequestForVideoId(videoId);
+ if (request != null) {
+ // This hook is always called off the main thread,
+ // but this can later be called for the same video id from the main thread.
+ // This is not a concern, since the fetch will always be finished
+ // and never block the main thread.
+ // But if debugging, then still verify this is the situation.
+ if (Settings.ENABLE_DEBUG_LOGGING.get() && !request.fetchCompleted() && Utils.isCurrentlyOnMainThread()) {
+ Logger.printException(() -> "Error: Blocking main thread");
+ }
+ var stream = request.getStream();
+ if (stream != null) {
+ Logger.printDebug(() -> "Overriding video stream: " + videoId);
+ return stream;
+ }
+ }
+
+ Logger.printDebug(() -> "Not overriding streaming data (video stream is null): " + videoId);
+ } catch (Exception ex) {
+ Logger.printException(() -> "getStreamingData failure", ex);
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Injection point.
+ * Called after {@link #getStreamingData(String)}.
+ */
+ @Nullable
+ public static byte[] removeVideoPlaybackPostBody(Uri uri, int method, byte[] postData) {
+ if (SPOOF_STREAMING_DATA) {
+ try {
+ final int methodPost = 2;
+ if (method == methodPost) {
+ String path = uri.getPath();
+ if (path != null && path.contains("videoplayback")) {
+ return null;
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "removeVideoPlaybackPostBody failure", ex);
+ }
+ }
+
+ return postData;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static String appendSpoofedClient(String videoFormat) {
+ try {
+ if (SPOOF_STREAMING_DATA && Settings.SPOOF_STREAMING_DATA_STATS_FOR_NERDS.get()
+ && !TextUtils.isEmpty(videoFormat)) {
+ // Force LTR layout, to match the same LTR video time/length layout YouTube uses for all languages
+ return "\u202D" + videoFormat + String.format("\u2009(%s)", StreamingDataRequest.getLastSpoofedClientName()); // u202D = left to right override
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "appendSpoofedClient failure", ex);
+ }
+
+ return videoFormat;
+ }
+
+ public static final class iOSAvailability implements Setting.Availability {
+ @Override
+ public boolean isAvailable() {
+ return Settings.SPOOF_STREAMING_DATA.get() && Settings.SPOOF_STREAMING_DATA_TYPE.get() == ClientType.IOS;
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/WatchHistoryPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/WatchHistoryPatch.java
new file mode 100644
index 0000000000..01a002be4d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/WatchHistoryPatch.java
@@ -0,0 +1,37 @@
+package app.revanced.extension.youtube.patches.misc;
+
+import android.net.Uri;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class WatchHistoryPatch {
+
+ public enum WatchHistoryType {
+ ORIGINAL,
+ REPLACE,
+ BLOCK
+ }
+
+ private static final Uri UNREACHABLE_HOST_URI = Uri.parse("https://127.0.0.0");
+ private static final String WWW_TRACKING_URL_AUTHORITY = "www.youtube.com";
+
+ public static Uri replaceTrackingUrl(Uri trackingUrl) {
+ final WatchHistoryType watchHistoryType = Settings.WATCH_HISTORY_TYPE.get();
+ if (watchHistoryType != WatchHistoryType.ORIGINAL) {
+ try {
+ if (watchHistoryType == WatchHistoryType.REPLACE) {
+ return trackingUrl.buildUpon().authority(WWW_TRACKING_URL_AUTHORITY).build();
+ } else if (watchHistoryType == WatchHistoryType.BLOCK) {
+ return UNREACHABLE_HOST_URI;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "replaceTrackingUrl failure", ex);
+ }
+ }
+
+ return trackingUrl;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/client/AppClient.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/client/AppClient.java
new file mode 100644
index 0000000000..79f72f997a
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/client/AppClient.java
@@ -0,0 +1,221 @@
+package app.revanced.extension.youtube.patches.misc.client;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.os.Build;
+
+import androidx.annotation.Nullable;
+
+public class AppClient {
+
+ // ANDROID
+ private static final String OS_NAME_ANDROID = "Android";
+
+ // IOS
+ /**
+ * The hardcoded client version of the iOS app used for InnerTube requests with this client.
+ *
+ *
+ * It can be extracted by getting the latest release version of the app on
+ * the App
+ * Store page of the YouTube app , in the {@code What’s New} section.
+ *
+ */
+ private static final String CLIENT_VERSION_IOS = "19.47.7";
+ private static final String DEVICE_MAKE_IOS = "Apple";
+ /**
+ * The device machine id for the iPhone XS Max (iPhone11,4), used to get 60fps.
+ * The device machine id for the iPhone 16 Pro Max (iPhone17,2), used to get HDR with AV1 hardware decoding.
+ *
+ *
+ * See this GitHub Gist for more
+ * information.
+ *
+ */
+ private static final String DEVICE_MODEL_IOS = DeviceHardwareSupport.allowAV1()
+ ? "iPhone17,2"
+ : "iPhone11,4";
+ private static final String OS_NAME_IOS = "iOS";
+ /**
+ * The minimum supported OS version for the iOS YouTube client is iOS 14.0.
+ * Using an invalid OS version will use the AVC codec.
+ */
+ private static final String OS_VERSION_IOS = DeviceHardwareSupport.allowVP9()
+ ? "18.1.1.22B91"
+ : "13.7.17H35";
+ private static final String USER_AGENT_VERSION_IOS = DeviceHardwareSupport.allowVP9()
+ ? "18_1_1"
+ : "13_7";
+ private static final String USER_AGENT_IOS = "com.google.ios.youtube/" +
+ CLIENT_VERSION_IOS +
+ "(" +
+ DEVICE_MODEL_IOS +
+ "; U; CPU iOS " +
+ USER_AGENT_VERSION_IOS +
+ " like Mac OS X)";
+
+ // ANDROID VR
+ /**
+ * The hardcoded client version of the Android VR app used for InnerTube requests with this client.
+ *
+ *
+ * It can be extracted by getting the latest release version of the app on
+ * the App
+ * Store page of the YouTube app , in the {@code Additional details} section.
+ *
+ */
+ private static final String CLIENT_VERSION_ANDROID_VR = "1.60.19";
+ /**
+ * The device machine id for the Meta Quest 3, used to get opus codec with the Android VR client.
+ *
+ *
+ * See this GitLab for more
+ * information.
+ *
+ */
+ private static final String DEVICE_MODEL_ANDROID_VR = "Quest 3";
+ private static final String OS_VERSION_ANDROID_VR = "12";
+ /**
+ * The SDK version for Android 12 is 31,
+ * but for some reason the build.props for the {@code Quest 3} state that the SDK version is 32.
+ */
+ private static final int ANDROID_SDK_VERSION_ANDROID_VR = 32;
+ /**
+ * Package name for YouTube VR (Google DayDream): com.google.android.apps.youtube.vr (Deprecated)
+ * Package name for YouTube VR (Meta Quests): com.google.android.apps.youtube.vr.oculus
+ * Package name for YouTube VR (ByteDance Pico 4): com.google.android.apps.youtube.vr.pico
+ */
+ private static final String USER_AGENT_ANDROID_VR = "com.google.android.apps.youtube.vr.oculus/" +
+ CLIENT_VERSION_ANDROID_VR +
+ " (Linux; U; Android " +
+ OS_VERSION_ANDROID_VR +
+ "; GB) gzip";
+
+ // ANDROID UNPLUGGED
+ private static final String CLIENT_VERSION_ANDROID_UNPLUGGED = "8.47.0";
+ /**
+ * The device machine id for the Chromecast with Google TV 4K.
+ *
+ *
+ * See this GitLab for more
+ * information.
+ *
+ */
+ private static final String DEVICE_MODEL_ANDROID_UNPLUGGED = "Google TV Streamer";
+ private static final String OS_VERSION_ANDROID_UNPLUGGED = "14";
+ private static final int ANDROID_SDK_VERSION_ANDROID_UNPLUGGED = 34;
+ private static final String USER_AGENT_ANDROID_UNPLUGGED = "com.google.android.apps.youtube.unplugged/" +
+ CLIENT_VERSION_ANDROID_UNPLUGGED +
+ " (Linux; U; Android " +
+ OS_VERSION_ANDROID_UNPLUGGED +
+ "; GB) gzip";
+
+ private AppClient() {
+ }
+
+ public enum ClientType {
+ IOS(5,
+ DEVICE_MAKE_IOS,
+ DEVICE_MODEL_IOS,
+ CLIENT_VERSION_IOS,
+ OS_NAME_IOS,
+ OS_VERSION_IOS,
+ null,
+ USER_AGENT_IOS,
+ false
+ ),
+ ANDROID_VR(28,
+ null,
+ DEVICE_MODEL_ANDROID_VR,
+ CLIENT_VERSION_ANDROID_VR,
+ OS_NAME_ANDROID,
+ OS_VERSION_ANDROID_VR,
+ ANDROID_SDK_VERSION_ANDROID_VR,
+ USER_AGENT_ANDROID_VR,
+ true
+ ),
+ ANDROID_UNPLUGGED(29,
+ null,
+ DEVICE_MODEL_ANDROID_UNPLUGGED,
+ CLIENT_VERSION_ANDROID_UNPLUGGED,
+ OS_NAME_ANDROID,
+ OS_VERSION_ANDROID_UNPLUGGED,
+ ANDROID_SDK_VERSION_ANDROID_UNPLUGGED,
+ USER_AGENT_ANDROID_UNPLUGGED,
+ true
+ );
+
+ public final String friendlyName;
+
+ /**
+ * YouTube
+ * client type
+ */
+ public final int id;
+
+ /**
+ * Device manufacturer.
+ */
+ @Nullable
+ public final String make;
+
+ /**
+ * Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
+ */
+ public final String deviceModel;
+
+ /**
+ * Device OS name.
+ */
+ @Nullable
+ public final String osName;
+
+ /**
+ * Device OS version.
+ */
+ public final String osVersion;
+
+ /**
+ * Player user-agent.
+ */
+ public final String userAgent;
+
+ /**
+ * Android SDK version, equivalent to {@link Build.VERSION#SDK} (System property: ro.build.version.sdk)
+ * Field is null if not applicable.
+ */
+ public final Integer androidSdkVersion;
+
+ /**
+ * App version.
+ */
+ public final String clientVersion;
+
+ /**
+ * If the client can access the API logged in.
+ */
+ public final boolean canLogin;
+
+ ClientType(int id,
+ @Nullable String make,
+ String deviceModel,
+ String clientVersion,
+ @Nullable String osName,
+ String osVersion,
+ Integer androidSdkVersion,
+ String userAgent,
+ boolean canLogin
+ ) {
+ this.friendlyName = str("revanced_spoof_streaming_data_type_entry_" + name().toLowerCase());
+ this.id = id;
+ this.make = make;
+ this.deviceModel = deviceModel;
+ this.clientVersion = clientVersion;
+ this.osName = osName;
+ this.osVersion = osVersion;
+ this.androidSdkVersion = androidSdkVersion;
+ this.userAgent = userAgent;
+ this.canLogin = canLogin;
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/client/DeviceHardwareSupport.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/client/DeviceHardwareSupport.java
new file mode 100644
index 0000000000..91ffd5aae5
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/client/DeviceHardwareSupport.java
@@ -0,0 +1,54 @@
+package app.revanced.extension.youtube.patches.misc.client;
+
+import static app.revanced.extension.shared.utils.Utils.isSDKAbove;
+
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+
+public class DeviceHardwareSupport {
+ private static final boolean DEVICE_HAS_HARDWARE_DECODING_VP9;
+ private static final boolean DEVICE_HAS_HARDWARE_DECODING_AV1;
+
+ static {
+ boolean vp9found = false;
+ boolean av1found = false;
+ MediaCodecList codecList = new MediaCodecList(MediaCodecList.ALL_CODECS);
+ final boolean deviceIsAndroidTenOrLater = isSDKAbove(29);
+
+ for (MediaCodecInfo codecInfo : codecList.getCodecInfos()) {
+ final boolean isHardwareAccelerated = deviceIsAndroidTenOrLater
+ ? codecInfo.isHardwareAccelerated()
+ : !codecInfo.getName().startsWith("OMX.google"); // Software decoder.
+ if (isHardwareAccelerated && !codecInfo.isEncoder()) {
+ for (String type : codecInfo.getSupportedTypes()) {
+ if (type.equalsIgnoreCase("video/x-vnd.on2.vp9")) {
+ vp9found = true;
+ } else if (type.equalsIgnoreCase("video/av01")) {
+ av1found = true;
+ }
+ }
+ }
+ }
+
+ DEVICE_HAS_HARDWARE_DECODING_VP9 = vp9found;
+ DEVICE_HAS_HARDWARE_DECODING_AV1 = av1found;
+
+ Logger.printDebug(() -> DEVICE_HAS_HARDWARE_DECODING_AV1
+ ? "Device supports AV1 hardware decoding\n"
+ : "Device does not support AV1 hardware decoding\n"
+ + (DEVICE_HAS_HARDWARE_DECODING_VP9
+ ? "Device supports VP9 hardware decoding"
+ : "Device does not support VP9 hardware decoding"));
+ }
+
+ public static boolean allowVP9() {
+ return DEVICE_HAS_HARDWARE_DECODING_VP9 && !Settings.SPOOF_STREAMING_DATA_IOS_FORCE_AVC.get();
+ }
+
+ public static boolean allowAV1() {
+ return allowVP9() && DEVICE_HAS_HARDWARE_DECODING_AV1;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/PlayerRoutes.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/PlayerRoutes.java
new file mode 100644
index 0000000000..7459f41a5c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/PlayerRoutes.java
@@ -0,0 +1,103 @@
+package app.revanced.extension.youtube.patches.misc.requests;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.util.Objects;
+
+import app.revanced.extension.shared.requests.Requester;
+import app.revanced.extension.shared.requests.Route;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType;
+
+@SuppressWarnings("deprecation")
+public final class PlayerRoutes {
+ /**
+ * The base URL of requests of non-web clients to the InnerTube internal API.
+ */
+ private static final String YOUTUBEI_V1_GAPIS_URL = "https://youtubei.googleapis.com/youtubei/v1/";
+
+ static final Route.CompiledRoute GET_STREAMING_DATA = new Route(
+ Route.Method.POST,
+ "player" +
+ "?fields=streamingData" +
+ "&alt=proto"
+ ).compile();
+
+ static final Route.CompiledRoute GET_PLAYLIST_PAGE = new Route(
+ Route.Method.POST,
+ "next" +
+ "?fields=contents.singleColumnWatchNextResults.playlist.playlist"
+ ).compile();
+
+ /**
+ * TCP connection and HTTP read timeout
+ */
+ private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds.
+
+ private PlayerRoutes() {
+ }
+
+ static String createInnertubeBody(ClientType clientType, String videoId) {
+ return createInnertubeBody(clientType, videoId, null);
+ }
+
+ static String createInnertubeBody(ClientType clientType, String videoId, String playlistId) {
+ JSONObject innerTubeBody = new JSONObject();
+
+ try {
+ JSONObject context = new JSONObject();
+
+ JSONObject client = new JSONObject();
+ client.put("clientName", clientType.name());
+ client.put("clientVersion", clientType.clientVersion);
+ client.put("deviceModel", clientType.deviceModel);
+ client.put("osVersion", clientType.osVersion);
+ if (clientType.make != null) {
+ client.put("deviceMake", clientType.make);
+ }
+ if (clientType.osName != null) {
+ client.put("osName", clientType.osName);
+ }
+ if (clientType.androidSdkVersion != null) {
+ client.put("androidSdkVersion", clientType.androidSdkVersion.toString());
+ }
+ String languageCode = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale.getLanguage();
+ client.put("hl", languageCode);
+
+ context.put("client", client);
+
+ innerTubeBody.put("context", context);
+ innerTubeBody.put("contentCheckOk", true);
+ innerTubeBody.put("racyCheckOk", true);
+ innerTubeBody.put("videoId", videoId);
+ if (playlistId != null) {
+ innerTubeBody.put("playlistId", playlistId);
+ }
+ } catch (JSONException e) {
+ Logger.printException(() -> "Failed to create innerTubeBody", e);
+ }
+
+ return innerTubeBody.toString();
+ }
+
+ /**
+ * @noinspection SameParameterValue
+ */
+ static HttpURLConnection getPlayerResponseConnectionFromRoute(Route.CompiledRoute route, ClientType clientType) throws IOException {
+ var connection = Requester.getConnectionFromCompiledRoute(YOUTUBEI_V1_GAPIS_URL, route);
+
+ connection.setRequestProperty("Content-Type", "application/json");
+ connection.setRequestProperty("User-Agent", clientType.userAgent);
+
+ connection.setUseCaches(false);
+ connection.setDoOutput(true);
+
+ connection.setConnectTimeout(CONNECTION_TIMEOUT_MILLISECONDS);
+ connection.setReadTimeout(CONNECTION_TIMEOUT_MILLISECONDS);
+ return connection;
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/PlaylistRequest.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/PlaylistRequest.java
new file mode 100644
index 0000000000..370e23cfc1
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/PlaylistRequest.java
@@ -0,0 +1,199 @@
+package app.revanced.extension.youtube.patches.misc.requests;
+
+import static app.revanced.extension.youtube.patches.misc.requests.PlayerRoutes.GET_PLAYLIST_PAGE;
+
+import android.annotation.SuppressLint;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+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.misc.client.AppClient.ClientType;
+import app.revanced.extension.youtube.shared.VideoInformation;
+
+public class PlaylistRequest {
+
+ /**
+ * How long to keep fetches until they are expired.
+ */
+ private static final long CACHE_RETENTION_TIME_MILLISECONDS = 60 * 1000; // 1 Minute
+
+ private static final long MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000; // 20 seconds
+
+ @GuardedBy("itself")
+ private static final Map cache = new HashMap<>();
+
+ @SuppressLint("ObsoleteSdkInt")
+ public static void fetchRequestIfNeeded(@Nullable String videoId) {
+ Objects.requireNonNull(videoId);
+ synchronized (cache) {
+ final long now = System.currentTimeMillis();
+
+ cache.values().removeIf(request -> {
+ final boolean expired = request.isExpired(now);
+ if (expired) Logger.printDebug(() -> "Removing expired stream: " + request.videoId);
+ return expired;
+ });
+
+ if (!cache.containsKey(videoId)) {
+ cache.put(videoId, new PlaylistRequest(videoId));
+ }
+ }
+ }
+
+ @Nullable
+ public static PlaylistRequest getRequestForVideoId(@Nullable String videoId) {
+ synchronized (cache) {
+ return cache.get(videoId);
+ }
+ }
+
+ private static void handleConnectionError(String toastMessage, @Nullable Exception ex) {
+ Logger.printInfo(() -> toastMessage, ex);
+ }
+
+ @Nullable
+ private static JSONObject send(ClientType clientType, String videoId) {
+ Objects.requireNonNull(clientType);
+ Objects.requireNonNull(videoId);
+
+ final long startTime = System.currentTimeMillis();
+ String clientTypeName = clientType.name();
+ Logger.printDebug(() -> "Fetching playlist request for: " + videoId + " using client: " + clientTypeName);
+
+ try {
+ HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_PLAYLIST_PAGE, clientType);
+
+ String innerTubeBody = PlayerRoutes.createInnertubeBody(
+ clientType,
+ videoId,
+ "RD" + videoId
+ );
+ byte[] requestBody = innerTubeBody.getBytes(StandardCharsets.UTF_8);
+ connection.setFixedLengthStreamingMode(requestBody.length);
+ connection.getOutputStream().write(requestBody);
+
+ final int responseCode = connection.getResponseCode();
+ if (responseCode == 200) return Requester.parseJSONObject(connection);
+
+ handleConnectionError(clientTypeName + " not available with response code: "
+ + responseCode + " message: " + connection.getResponseMessage(),
+ null);
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError("Connection timeout", ex);
+ } catch (IOException ex) {
+ handleConnectionError("Network error", ex);
+ } catch (Exception ex) {
+ Logger.printException(() -> "send failed", ex);
+ } finally {
+ Logger.printDebug(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms");
+ }
+
+ return null;
+ }
+
+ private static Boolean fetch(@NonNull String videoId) {
+ final ClientType clientType = ClientType.ANDROID_VR;
+ final JSONObject playlistJson = send(clientType, videoId);
+ if (playlistJson != null) {
+ try {
+ final JSONObject singleColumnWatchNextResultsJsonObject = playlistJson
+ .getJSONObject("contents")
+ .getJSONObject("singleColumnWatchNextResults");
+
+ if (!singleColumnWatchNextResultsJsonObject.has("playlist")) {
+ return false;
+ }
+
+ final JSONObject playlistJsonObject = singleColumnWatchNextResultsJsonObject
+ .getJSONObject("playlist")
+ .getJSONObject("playlist");
+
+ final Object currentStreamObject = playlistJsonObject
+ .getJSONArray("contents")
+ .get(0);
+
+ if (!(currentStreamObject instanceof JSONObject currentStreamJsonObject)) {
+ return false;
+ }
+
+ final JSONObject watchEndpointJsonObject = currentStreamJsonObject
+ .getJSONObject("playlistPanelVideoRenderer")
+ .getJSONObject("navigationEndpoint")
+ .getJSONObject("watchEndpoint");
+
+ Logger.printDebug(() -> "watchEndpoint: " + watchEndpointJsonObject);
+
+ return watchEndpointJsonObject.has("playerParams") &&
+ VideoInformation.isMixPlaylistsOpenedByUser(watchEndpointJsonObject.getString("playerParams"));
+ } catch (JSONException e) {
+ Logger.printDebug(() -> "Fetch failed while processing response data for response: " + playlistJson);
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Time this instance and the fetch future was created.
+ */
+ private final long timeFetched;
+ private final String videoId;
+ private final Future future;
+
+ private PlaylistRequest(String videoId) {
+ this.timeFetched = System.currentTimeMillis();
+ this.videoId = videoId;
+ this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId));
+ }
+
+ public boolean isExpired(long now) {
+ final long timeSinceCreation = now - timeFetched;
+ if (timeSinceCreation > CACHE_RETENTION_TIME_MILLISECONDS) {
+ return true;
+ }
+
+ // Only expired if the fetch failed (API null response).
+ return (fetchCompleted() && getStream() == null);
+ }
+
+ /**
+ * @return if the fetch call has completed.
+ */
+ public boolean fetchCompleted() {
+ return future.isDone();
+ }
+
+ public Boolean getStream() {
+ try {
+ return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ex) {
+ Logger.printInfo(() -> "getStream timed out", ex);
+ } catch (InterruptedException ex) {
+ Logger.printException(() -> "getStream interrupted", ex);
+ Thread.currentThread().interrupt(); // Restore interrupt status flag.
+ } catch (ExecutionException ex) {
+ Logger.printException(() -> "getStream failure", ex);
+ }
+
+ return null;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/StreamingDataRequest.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/StreamingDataRequest.java
new file mode 100644
index 0000000000..6d09ccc057
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/misc/requests/StreamingDataRequest.java
@@ -0,0 +1,242 @@
+package app.revanced.extension.youtube.patches.misc.requests;
+
+import static app.revanced.extension.youtube.patches.misc.requests.PlayerRoutes.GET_STREAMING_DATA;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.BufferedInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.HttpURLConnection;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import app.revanced.extension.shared.patches.components.ByteArrayFilterGroup;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType;
+import app.revanced.extension.youtube.settings.Settings;
+
+public class StreamingDataRequest {
+ private static final ClientType[] ALL_CLIENT_TYPES = ClientType.values();
+ private static final ClientType[] CLIENT_ORDER_TO_USE;
+
+ static {
+ ClientType preferredClient = Settings.SPOOF_STREAMING_DATA_TYPE.get();
+ CLIENT_ORDER_TO_USE = new ClientType[ALL_CLIENT_TYPES.length];
+
+ CLIENT_ORDER_TO_USE[0] = preferredClient;
+
+ int i = 1;
+ for (ClientType c : ALL_CLIENT_TYPES) {
+ if (c != preferredClient) {
+ CLIENT_ORDER_TO_USE[i++] = c;
+ }
+ }
+ }
+
+ private static ClientType lastSpoofedClientType;
+
+ public static String getLastSpoofedClientName() {
+ return lastSpoofedClientType == null
+ ? "Unknown"
+ : lastSpoofedClientType.friendlyName;
+ }
+
+ /**
+ * TCP connection and HTTP read timeout.
+ */
+ private static final int HTTP_TIMEOUT_MILLISECONDS = 10 * 1000;
+
+ /**
+ * Any arbitrarily large value, but must be at least twice {@link #HTTP_TIMEOUT_MILLISECONDS}
+ */
+ private static final int MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000;
+
+ @GuardedBy("itself")
+ private static final Map cache = Collections.synchronizedMap(
+ new LinkedHashMap<>(100) {
+ /**
+ * Cache limit must be greater than the maximum number of videos open at once,
+ * which theoretically is more than 4 (3 Shorts + one regular minimized video).
+ * But instead use a much larger value, to handle if a video viewed a while ago
+ * is somehow still referenced. Each stream is a small array of Strings
+ * so memory usage is not a concern.
+ */
+ private static final int CACHE_LIMIT = 50;
+
+ @Override
+ protected boolean removeEldestEntry(Entry eldest) {
+ return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit.
+ }
+ });
+
+ public static void fetchRequest(@NonNull String videoId, Map fetchHeaders) {
+ cache.put(videoId, new StreamingDataRequest(videoId, fetchHeaders));
+ }
+
+ @Nullable
+ public static StreamingDataRequest getRequestForVideoId(@Nullable String videoId) {
+ return cache.get(videoId);
+ }
+
+ private static void handleConnectionError(String toastMessage, @Nullable Exception ex) {
+ Logger.printInfo(() -> toastMessage, ex);
+ }
+
+ // Available only to logged in users.
+ 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 void writeInnerTubeBody(HttpURLConnection connection, ClientType clientType,
+ String videoId, Map playerHeaders) {
+ try {
+ connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS);
+ connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS);
+
+ if (playerHeaders != null) {
+ for (String key : REQUEST_HEADER_KEYS) {
+ if (!clientType.canLogin && key.equals(AUTHORIZATION_HEADER)) {
+ continue;
+ }
+ String value = playerHeaders.get(key);
+ if (value != null) {
+ connection.setRequestProperty(key, value);
+ }
+ }
+ }
+
+ String innerTubeBody = PlayerRoutes.createInnertubeBody(clientType, videoId);
+ byte[] requestBody = innerTubeBody.getBytes(StandardCharsets.UTF_8);
+ connection.setFixedLengthStreamingMode(requestBody.length);
+ connection.getOutputStream().write(requestBody);
+ } catch (IOException ex) {
+ handleConnectionError("Network error", ex);
+ }
+ }
+
+ @Nullable
+ private static HttpURLConnection send(ClientType clientType, String videoId,
+ Map playerHeaders) {
+ Objects.requireNonNull(clientType);
+ Objects.requireNonNull(videoId);
+ Objects.requireNonNull(playerHeaders);
+
+ final long startTime = System.currentTimeMillis();
+ String clientTypeName = clientType.name();
+ Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType.name());
+
+ try {
+ HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA, clientType);
+ writeInnerTubeBody(connection, clientType, videoId, playerHeaders);
+
+ final int responseCode = connection.getResponseCode();
+ if (responseCode == 200) return connection;
+
+ handleConnectionError(clientTypeName + " not available with response code: "
+ + responseCode + " message: " + connection.getResponseMessage(),
+ null);
+ } catch (Exception ex) {
+ Logger.printException(() -> "send failed", ex);
+ } finally {
+ Logger.printDebug(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms");
+ }
+
+ return null;
+ }
+
+ private static final ByteArrayFilterGroup liveStreams =
+ new ByteArrayFilterGroup(
+ Settings.SPOOF_STREAMING_DATA_IOS_SKIP_LIVESTREAM_PLAYBACK,
+ "yt_live_broadcast",
+ "yt_premiere_broadcast"
+ );
+
+ private static ByteBuffer fetch(@NonNull String videoId, Map playerHeaders) {
+ try {
+ lastSpoofedClientType = null;
+
+ // Retry with different client if empty response body is received.
+ for (ClientType clientType : CLIENT_ORDER_TO_USE) {
+ HttpURLConnection connection = send(clientType, videoId, playerHeaders);
+
+ // gzip encoding doesn't response with content length (-1),
+ // but empty response body does.
+ if (connection == null || connection.getContentLength() == 0) {
+ continue;
+ }
+ InputStream inputStream = new BufferedInputStream(connection.getInputStream());
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ byte[] buffer = new byte[2048];
+ int bytesRead;
+ while ((bytesRead = inputStream.read(buffer)) >= 0) {
+ baos.write(buffer, 0, bytesRead);
+ }
+ inputStream.close();
+ if (clientType == ClientType.IOS && liveStreams.check(buffer).isFiltered()) {
+ Logger.printDebug(() -> "Ignore IOS spoofing as it is a livestream (video: " + videoId + ")");
+ continue;
+ }
+ lastSpoofedClientType = clientType;
+
+ return ByteBuffer.wrap(baos.toByteArray());
+ }
+ } catch (IOException ex) {
+ Logger.printException(() -> "Fetch failed while processing response data", ex);
+ }
+
+ handleConnectionError("Could not fetch any client streams", null);
+ return null;
+ }
+
+ private final String videoId;
+ private final Future future;
+
+ private StreamingDataRequest(String videoId, Map playerHeaders) {
+ Objects.requireNonNull(playerHeaders);
+ this.videoId = videoId;
+ this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playerHeaders));
+ }
+
+ public boolean fetchCompleted() {
+ return future.isDone();
+ }
+
+ @Nullable
+ public ByteBuffer getStream() {
+ try {
+ return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ex) {
+ Logger.printInfo(() -> "getStream timed out", ex);
+ } catch (InterruptedException ex) {
+ Logger.printException(() -> "getStream interrupted", ex);
+ Thread.currentThread().interrupt(); // Restore interrupt status flag.
+ } catch (ExecutionException ex) {
+ Logger.printException(() -> "getStream failure", ex);
+ }
+
+ return null;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "StreamingDataRequest{" + "videoId='" + videoId + '\'' + '}';
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/AlwaysRepeat.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/AlwaysRepeat.java
new file mode 100644
index 0000000000..777831a1e5
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/AlwaysRepeat.java
@@ -0,0 +1,59 @@
+package app.revanced.extension.youtube.patches.overlaybutton;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class AlwaysRepeat extends BottomControlButton {
+ @Nullable
+ private static AlwaysRepeat instance;
+
+ public AlwaysRepeat(ViewGroup bottomControlsViewGroup) {
+ super(
+ bottomControlsViewGroup,
+ "always_repeat_button",
+ Settings.OVERLAY_BUTTON_ALWAYS_REPEAT,
+ Settings.ALWAYS_REPEAT,
+ Settings.ALWAYS_REPEAT_PAUSE,
+ view -> {
+ if (instance != null)
+ instance.changeSelected(!view.isSelected());
+ },
+ view -> {
+ if (instance != null)
+ instance.changeColorFilter();
+ return true;
+ }
+ );
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void initialize(View bottomControlsViewGroup) {
+ try {
+ if (bottomControlsViewGroup instanceof ViewGroup viewGroup) {
+ instance = new AlwaysRepeat(viewGroup);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "initialize failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void changeVisibility(boolean showing, boolean animation) {
+ if (instance != null) instance.setVisibility(showing, animation);
+ }
+
+ public static void changeVisibilityNegatedImmediate() {
+ if (instance != null) instance.setVisibilityNegatedImmediate();
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/BottomControlButton.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/BottomControlButton.java
new file mode 100644
index 0000000000..da4744f5a8
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/BottomControlButton.java
@@ -0,0 +1,174 @@
+package app.revanced.extension.youtube.patches.overlaybutton;
+
+import static app.revanced.extension.shared.utils.ResourceUtils.getAnimation;
+import static app.revanced.extension.shared.utils.ResourceUtils.getInteger;
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.shared.utils.Utils.getChildView;
+
+import android.graphics.Color;
+import android.graphics.ColorFilter;
+import android.graphics.PorterDuff;
+import android.graphics.PorterDuffColorFilter;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.animation.Animation;
+import android.widget.ImageView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.lang.ref.WeakReference;
+import java.util.Objects;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+public abstract class BottomControlButton {
+ private static final Animation fadeIn;
+ private static final Animation fadeOut;
+ private static final Animation fadeOutImmediate;
+
+ private final ColorFilter cf =
+ new PorterDuffColorFilter(Color.parseColor("#fffffc79"), PorterDuff.Mode.SRC_ATOP);
+
+ private final WeakReference buttonRef;
+ private final BooleanSetting setting;
+ private final BooleanSetting primaryInteractionSetting;
+ private final BooleanSetting secondaryInteractionSetting;
+ protected boolean isVisible;
+
+ static {
+ fadeIn = getAnimation("fade_in");
+ // android.R.integer.config_shortAnimTime, 200
+ fadeIn.setDuration(getInteger("fade_duration_fast"));
+
+ fadeOut = getAnimation("fade_out");
+ // android.R.integer.config_mediumAnimTime, 400
+ fadeOut.setDuration(getInteger("fade_overlay_fade_duration"));
+
+ fadeOutImmediate = getAnimation("abc_fade_out");
+ // android.R.integer.config_shortAnimTime, 200
+ fadeOutImmediate.setDuration(getInteger("fade_duration_fast"));
+ }
+
+ @NonNull
+ public static Animation getButtonFadeIn() {
+ return fadeIn;
+ }
+
+ @NonNull
+ public static Animation getButtonFadeOut() {
+ return fadeOut;
+ }
+
+ @NonNull
+ public static Animation getButtonFadeOutImmediate() {
+ return fadeOutImmediate;
+ }
+
+ public BottomControlButton(@NonNull ViewGroup bottomControlsViewGroup, @NonNull String imageViewButtonId, @NonNull BooleanSetting booleanSetting,
+ @NonNull View.OnClickListener onClickListener, @Nullable View.OnLongClickListener longClickListener) {
+ this(bottomControlsViewGroup, imageViewButtonId, booleanSetting, null, null, onClickListener, longClickListener);
+ }
+
+ @SuppressWarnings("unused")
+ public BottomControlButton(@NonNull ViewGroup bottomControlsViewGroup, @NonNull String imageViewButtonId, @NonNull BooleanSetting booleanSetting, @Nullable BooleanSetting primaryInteractionSetting,
+ @NonNull View.OnClickListener onClickListener, @Nullable View.OnLongClickListener longClickListener) {
+ this(bottomControlsViewGroup, imageViewButtonId, booleanSetting, primaryInteractionSetting, null, onClickListener, longClickListener);
+ }
+
+ public BottomControlButton(@NonNull ViewGroup bottomControlsViewGroup, @NonNull String imageViewButtonId, @NonNull BooleanSetting booleanSetting,
+ @Nullable BooleanSetting primaryInteractionSetting, @Nullable BooleanSetting secondaryInteractionSetting,
+ @NonNull View.OnClickListener onClickListener, @Nullable View.OnLongClickListener longClickListener) {
+ Logger.printDebug(() -> "Initializing button: " + imageViewButtonId);
+
+ setting = booleanSetting;
+
+ // Create the button.
+ ImageView imageView = Objects.requireNonNull(getChildView(bottomControlsViewGroup, imageViewButtonId));
+ imageView.setOnClickListener(onClickListener);
+ this.primaryInteractionSetting = primaryInteractionSetting;
+ this.secondaryInteractionSetting = secondaryInteractionSetting;
+ if (primaryInteractionSetting != null) {
+ imageView.setSelected(primaryInteractionSetting.get());
+ }
+ if (secondaryInteractionSetting != null) {
+ setColorFilter(imageView, secondaryInteractionSetting.get());
+ }
+ if (longClickListener != null) {
+ imageView.setOnLongClickListener(longClickListener);
+ }
+ imageView.setVisibility(View.GONE);
+ buttonRef = new WeakReference<>(imageView);
+ }
+
+ public void changeActivated(boolean activated) {
+ ImageView imageView = buttonRef.get();
+ if (imageView == null)
+ return;
+ imageView.setActivated(activated);
+ }
+
+ public void changeSelected(boolean selected) {
+ ImageView imageView = buttonRef.get();
+ if (imageView == null || primaryInteractionSetting == null)
+ return;
+
+ if (imageView.getColorFilter() == cf) {
+ Utils.showToastShort(str("revanced_overlay_button_not_allowed_warning"));
+ return;
+ }
+
+ imageView.setSelected(selected);
+ primaryInteractionSetting.save(selected);
+ }
+
+ public void changeColorFilter() {
+ ImageView imageView = buttonRef.get();
+ if (imageView == null) return;
+ if (primaryInteractionSetting == null || secondaryInteractionSetting == null)
+ return;
+
+ imageView.setSelected(true);
+ primaryInteractionSetting.save(true);
+
+ final boolean newValue = !secondaryInteractionSetting.get();
+ secondaryInteractionSetting.save(newValue);
+ setColorFilter(imageView, newValue);
+ }
+
+ public void setColorFilter(ImageView imageView, boolean selected) {
+ if (selected)
+ imageView.setColorFilter(cf);
+ else
+ imageView.clearColorFilter();
+ }
+
+ public void setVisibility(boolean visible, boolean animation) {
+ ImageView imageView = buttonRef.get();
+ if (imageView == null || isVisible == visible) return;
+ isVisible = visible;
+
+ imageView.clearAnimation();
+ if (visible && setting.get()) {
+ imageView.setVisibility(View.VISIBLE);
+ if (animation) imageView.startAnimation(fadeIn);
+ return;
+ }
+ if (imageView.getVisibility() == View.VISIBLE) {
+ if (animation) imageView.startAnimation(fadeOut);
+ imageView.setVisibility(View.GONE);
+ }
+ }
+
+ public void setVisibilityNegatedImmediate() {
+ ImageView imageView = buttonRef.get();
+ if (imageView == null) return;
+ if (!setting.get()) return;
+
+ imageView.clearAnimation();
+ imageView.startAnimation(fadeOutImmediate);
+ imageView.setVisibility(View.GONE);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/CopyVideoUrl.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/CopyVideoUrl.java
new file mode 100644
index 0000000000..33e7e88bbb
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/CopyVideoUrl.java
@@ -0,0 +1,55 @@
+package app.revanced.extension.youtube.patches.overlaybutton;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.utils.VideoUtils;
+
+@SuppressWarnings("unused")
+public class CopyVideoUrl extends BottomControlButton {
+ @Nullable
+ private static CopyVideoUrl instance;
+
+ public CopyVideoUrl(ViewGroup bottomControlsViewGroup) {
+ super(
+ bottomControlsViewGroup,
+ "copy_video_url_button",
+ Settings.OVERLAY_BUTTON_COPY_VIDEO_URL,
+ view -> VideoUtils.copyUrl(false),
+ view -> {
+ VideoUtils.copyUrl(true);
+ return true;
+ }
+ );
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void initialize(View bottomControlsViewGroup) {
+ try {
+ if (bottomControlsViewGroup instanceof ViewGroup viewGroup) {
+ instance = new CopyVideoUrl(viewGroup);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "initialize failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void changeVisibility(boolean showing, boolean animation) {
+ if (instance != null) instance.setVisibility(showing, animation);
+ }
+
+ public static void changeVisibilityNegatedImmediate() {
+ if (instance != null) instance.setVisibilityNegatedImmediate();
+ }
+
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/CopyVideoUrlTimestamp.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/CopyVideoUrlTimestamp.java
new file mode 100644
index 0000000000..bfda8216b2
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/CopyVideoUrlTimestamp.java
@@ -0,0 +1,55 @@
+package app.revanced.extension.youtube.patches.overlaybutton;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.utils.VideoUtils;
+
+@SuppressWarnings("unused")
+public class CopyVideoUrlTimestamp extends BottomControlButton {
+ @Nullable
+ private static CopyVideoUrlTimestamp instance;
+
+ public CopyVideoUrlTimestamp(ViewGroup bottomControlsViewGroup) {
+ super(
+ bottomControlsViewGroup,
+ "copy_video_url_timestamp_button",
+ Settings.OVERLAY_BUTTON_COPY_VIDEO_URL_TIMESTAMP,
+ view -> VideoUtils.copyUrl(true),
+ view -> {
+ VideoUtils.copyTimeStamp();
+ return true;
+ }
+ );
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void initialize(View bottomControlsViewGroup) {
+ try {
+ if (bottomControlsViewGroup instanceof ViewGroup viewGroup) {
+ instance = new CopyVideoUrlTimestamp(viewGroup);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "initialize failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void changeVisibility(boolean showing, boolean animation) {
+ if (instance != null) instance.setVisibility(showing, animation);
+ }
+
+ public static void changeVisibilityNegatedImmediate() {
+ if (instance != null) instance.setVisibilityNegatedImmediate();
+ }
+
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/ExternalDownload.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/ExternalDownload.java
new file mode 100644
index 0000000000..e6a572af64
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/ExternalDownload.java
@@ -0,0 +1,52 @@
+package app.revanced.extension.youtube.patches.overlaybutton;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.utils.VideoUtils;
+
+@SuppressWarnings("unused")
+public class ExternalDownload extends BottomControlButton {
+ @Nullable
+ private static ExternalDownload instance;
+
+ public ExternalDownload(ViewGroup bottomControlsViewGroup) {
+ super(
+ bottomControlsViewGroup,
+ "external_download_button",
+ Settings.OVERLAY_BUTTON_EXTERNAL_DOWNLOADER,
+ view -> VideoUtils.launchVideoExternalDownloader(),
+ null
+ );
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void initialize(View bottomControlsViewGroup) {
+ try {
+ if (bottomControlsViewGroup instanceof ViewGroup viewGroup) {
+ instance = new ExternalDownload(viewGroup);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "initialize failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void changeVisibility(boolean showing, boolean animation) {
+ if (instance != null) instance.setVisibility(showing, animation);
+ }
+
+ public static void changeVisibilityNegatedImmediate() {
+ if (instance != null) instance.setVisibilityNegatedImmediate();
+ }
+
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/MuteVolume.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/MuteVolume.java
new file mode 100644
index 0000000000..532bc0a62e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/MuteVolume.java
@@ -0,0 +1,76 @@
+package app.revanced.extension.youtube.patches.overlaybutton;
+
+import android.content.Context;
+import android.media.AudioManager;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings({"deprecation", "unused"})
+public class MuteVolume extends BottomControlButton {
+ @Nullable
+ private static MuteVolume instance;
+ private static AudioManager audioManager;
+ private static final int stream = AudioManager.STREAM_MUSIC;
+
+ public MuteVolume(ViewGroup bottomControlsViewGroup) {
+ super(
+ bottomControlsViewGroup,
+ "mute_volume_button",
+ Settings.OVERLAY_BUTTON_MUTE_VOLUME,
+ view -> {
+ if (instance != null && audioManager != null) {
+ boolean unMuted = !audioManager.isStreamMute(stream);
+ audioManager.setStreamMute(stream, unMuted);
+ instance.changeActivated(unMuted);
+ }
+ },
+ null
+ );
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void initialize(View bottomControlsViewGroup) {
+ try {
+ if (bottomControlsViewGroup instanceof ViewGroup viewGroup) {
+ instance = new MuteVolume(viewGroup);
+ }
+ if (bottomControlsViewGroup.getContext().getSystemService(Context.AUDIO_SERVICE) instanceof AudioManager am) {
+ audioManager = am;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "initialize failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void changeVisibility(boolean showing, boolean animation) {
+ if (instance != null) {
+ instance.setVisibility(showing, animation);
+ changeActivated(instance);
+ }
+ }
+
+ public static void changeVisibilityNegatedImmediate() {
+ if (instance != null) {
+ instance.setVisibilityNegatedImmediate();
+ changeActivated(instance);
+ }
+ }
+
+ private static void changeActivated(MuteVolume instance) {
+ if (audioManager != null) {
+ boolean muted = audioManager.isStreamMute(stream);
+ instance.changeActivated(muted);
+ }
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/PlayAll.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/PlayAll.java
new file mode 100644
index 0000000000..25df9ae4b3
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/PlayAll.java
@@ -0,0 +1,55 @@
+package app.revanced.extension.youtube.patches.overlaybutton;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.utils.VideoUtils;
+
+@SuppressWarnings("unused")
+public class PlayAll extends BottomControlButton {
+
+ @Nullable
+ private static PlayAll instance;
+
+ public PlayAll(ViewGroup bottomControlsViewGroup) {
+ super(
+ bottomControlsViewGroup,
+ "play_all_button",
+ Settings.OVERLAY_BUTTON_PLAY_ALL,
+ view -> VideoUtils.openVideo(Settings.OVERLAY_BUTTON_PLAY_ALL_TYPE.get()),
+ view -> {
+ VideoUtils.openVideo();
+ return true;
+ }
+ );
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void initialize(View bottomControlsViewGroup) {
+ try {
+ if (bottomControlsViewGroup instanceof ViewGroup viewGroup) {
+ instance = new PlayAll(viewGroup);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "initialize failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void changeVisibility(boolean showing, boolean animation) {
+ if (instance != null) instance.setVisibility(showing, animation);
+ }
+
+ public static void changeVisibilityNegatedImmediate() {
+ if (instance != null) instance.setVisibilityNegatedImmediate();
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/SpeedDialog.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/SpeedDialog.java
new file mode 100644
index 0000000000..a091f36c62
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/SpeedDialog.java
@@ -0,0 +1,68 @@
+package app.revanced.extension.youtube.patches.overlaybutton;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.shared.utils.Utils.showToastShort;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.VideoInformation;
+import app.revanced.extension.youtube.utils.VideoUtils;
+
+@SuppressWarnings("unused")
+public class SpeedDialog extends BottomControlButton {
+ @Nullable
+ private static SpeedDialog instance;
+
+ public SpeedDialog(ViewGroup bottomControlsViewGroup) {
+ super(
+ bottomControlsViewGroup,
+ "speed_dialog_button",
+ Settings.OVERLAY_BUTTON_SPEED_DIALOG,
+ view -> VideoUtils.showPlaybackSpeedDialog(view.getContext()),
+ view -> {
+ if (!Settings.REMEMBER_PLAYBACK_SPEED_LAST_SELECTED.get() ||
+ VideoInformation.getPlaybackSpeed() == Settings.DEFAULT_PLAYBACK_SPEED.get()) {
+ VideoInformation.overridePlaybackSpeed(1.0f);
+ showToastShort(str("revanced_overlay_button_speed_dialog_reset", "1.0"));
+ } else {
+ float defaultSpeed = Settings.DEFAULT_PLAYBACK_SPEED.get();
+ VideoInformation.overridePlaybackSpeed(defaultSpeed);
+ showToastShort(str("revanced_overlay_button_speed_dialog_reset", defaultSpeed));
+ }
+
+ return true;
+ }
+ );
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void initialize(View bottomControlsViewGroup) {
+ try {
+ if (bottomControlsViewGroup instanceof ViewGroup viewGroup) {
+ instance = new SpeedDialog(viewGroup);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "initialize failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void changeVisibility(boolean showing, boolean animation) {
+ if (instance != null) instance.setVisibility(showing, animation);
+ }
+
+ public static void changeVisibilityNegatedImmediate() {
+ if (instance != null) instance.setVisibilityNegatedImmediate();
+ }
+
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/Whitelists.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/Whitelists.java
new file mode 100644
index 0000000000..e88cacd001
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/overlaybutton/Whitelists.java
@@ -0,0 +1,55 @@
+package app.revanced.extension.youtube.patches.overlaybutton;
+
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.settings.preference.WhitelistedChannelsPreference;
+import app.revanced.extension.youtube.whitelist.Whitelist;
+
+@SuppressWarnings("unused")
+public class Whitelists extends BottomControlButton {
+ @Nullable
+ private static Whitelists instance;
+
+ public Whitelists(ViewGroup bottomControlsViewGroup) {
+ super(
+ bottomControlsViewGroup,
+ "whitelist_button",
+ Settings.OVERLAY_BUTTON_WHITELIST,
+ view -> Whitelist.showWhitelistDialog(view.getContext()),
+ view -> {
+ WhitelistedChannelsPreference.showWhitelistedChannelDialog(view.getContext());
+ return true;
+ }
+ );
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void initialize(View bottomControlsViewGroup) {
+ try {
+ if (bottomControlsViewGroup instanceof ViewGroup viewGroup) {
+ instance = new Whitelists(viewGroup);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "initialize failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void changeVisibility(boolean showing, boolean animation) {
+ if (instance != null) instance.setVisibility(showing, animation);
+ }
+
+ public static void changeVisibilityNegatedImmediate() {
+ if (instance != null) instance.setVisibilityNegatedImmediate();
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/player/PlayerPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/player/PlayerPatch.java
new file mode 100644
index 0000000000..688a9901a2
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/player/PlayerPatch.java
@@ -0,0 +1,730 @@
+package app.revanced.extension.youtube.patches.player;
+
+import static app.revanced.extension.shared.utils.Utils.hideViewBy0dpUnderCondition;
+import static app.revanced.extension.shared.utils.Utils.hideViewByRemovingFromParentUnderCondition;
+import static app.revanced.extension.shared.utils.Utils.hideViewUnderCondition;
+import static app.revanced.extension.youtube.utils.ExtendedUtils.validateValue;
+
+import android.app.Activity;
+import android.content.pm.ActivityInfo;
+import android.graphics.Color;
+import android.support.v7.widget.RecyclerView;
+import android.util.TypedValue;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.coordinatorlayout.widget.CoordinatorLayout;
+
+import java.lang.ref.WeakReference;
+import java.util.Objects;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.IntegerSetting;
+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.InitializationPatch;
+import app.revanced.extension.youtube.patches.utils.PatchStatus;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+import app.revanced.extension.youtube.shared.RootView;
+import app.revanced.extension.youtube.shared.ShortsPlayerState;
+import app.revanced.extension.youtube.shared.VideoInformation;
+import app.revanced.extension.youtube.utils.VideoUtils;
+
+@SuppressWarnings("unused")
+public class PlayerPatch {
+ private static final IntegerSetting quickActionsMarginTopSetting = Settings.QUICK_ACTIONS_TOP_MARGIN;
+
+ private static final int PLAYER_OVERLAY_OPACITY_LEVEL;
+ private static final int QUICK_ACTIONS_MARGIN_TOP;
+ private static final float SPEED_OVERLAY_VALUE;
+
+ static {
+ final int opacity = validateValue(
+ Settings.CUSTOM_PLAYER_OVERLAY_OPACITY,
+ 0,
+ 100,
+ "revanced_custom_player_overlay_opacity_invalid_toast"
+ );
+ PLAYER_OVERLAY_OPACITY_LEVEL = (opacity * 255) / 100;
+
+ SPEED_OVERLAY_VALUE = validateValue(
+ Settings.SPEED_OVERLAY_VALUE,
+ 0.0f,
+ 8.0f,
+ "revanced_speed_overlay_value_invalid_toast"
+ );
+
+ final int topMargin = validateValue(
+ Settings.QUICK_ACTIONS_TOP_MARGIN,
+ 0,
+ 32,
+ "revanced_quick_actions_top_margin_invalid_toast"
+ );
+
+ QUICK_ACTIONS_MARGIN_TOP = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, (float) topMargin, Utils.getResources().getDisplayMetrics());
+ }
+
+ // region [Ambient mode control] patch
+
+ public static boolean bypassAmbientModeRestrictions(boolean original) {
+ return (!Settings.BYPASS_AMBIENT_MODE_RESTRICTIONS.get() && original) || Settings.DISABLE_AMBIENT_MODE.get();
+ }
+
+ public static boolean disableAmbientModeInFullscreen() {
+ return !Settings.DISABLE_AMBIENT_MODE_IN_FULLSCREEN.get();
+ }
+
+ // endregion
+
+ // region [Change player flyout menu toggles] patch
+
+ public static boolean changeSwitchToggle(boolean original) {
+ return !Settings.CHANGE_PLAYER_FLYOUT_MENU_TOGGLE.get() && original;
+ }
+
+ public static String getToggleString(String str) {
+ return ResourceUtils.getString(str);
+ }
+
+ // endregion
+
+ // region [Description components] patch
+
+ public static boolean disableRollingNumberAnimations() {
+ return Settings.DISABLE_ROLLING_NUMBER_ANIMATIONS.get();
+ }
+
+ /**
+ * view id R.id.content
+ */
+ private static final int contentId = ResourceUtils.getIdIdentifier("content");
+ private static final boolean expandDescriptionEnabled = Settings.EXPAND_VIDEO_DESCRIPTION.get();
+ private static final String descriptionString = Settings.EXPAND_VIDEO_DESCRIPTION_STRINGS.get();
+
+ private static boolean isDescriptionPanel = false;
+
+ public static void setContentDescription(String contentDescription) {
+ if (!expandDescriptionEnabled) {
+ return;
+ }
+ if (contentDescription == null || contentDescription.isEmpty()) {
+ isDescriptionPanel = false;
+ return;
+ }
+ if (descriptionString.isEmpty()) {
+ isDescriptionPanel = false;
+ return;
+ }
+ isDescriptionPanel = descriptionString.equals(contentDescription);
+ }
+
+ /**
+ * The last time the clickDescriptionView method was called.
+ */
+ private static long lastTimeDescriptionViewInvoked;
+
+
+ public static void onVideoDescriptionCreate(RecyclerView recyclerView) {
+ if (!expandDescriptionEnabled)
+ return;
+
+ recyclerView.getViewTreeObserver().addOnDrawListener(() -> {
+ try {
+ // Video description panel is only open when the player is active.
+ if (!RootView.isPlayerActive()) {
+ return;
+ }
+ // Video description's recyclerView is a child view of [contentId].
+ if (!(recyclerView.getParent().getParent() instanceof View contentView)) {
+ return;
+ }
+ if (contentView.getId() != contentId) {
+ return;
+ }
+ // This method is invoked whenever the Engagement panel is opened. (Description, Chapters, Comments, etc.)
+ // Check the title of the Engagement panel to prevent unnecessary clicking.
+ if (!isDescriptionPanel) {
+ return;
+ }
+ // The first view group contains information such as the video's title, like count, and number of views.
+ if (!(recyclerView.getChildAt(0) instanceof ViewGroup primaryViewGroup)) {
+ return;
+ }
+ if (primaryViewGroup.getChildCount() < 2) {
+ return;
+ }
+ // Typically, descriptionView is placed as the second child of recyclerView.
+ if (recyclerView.getChildAt(1) instanceof ViewGroup viewGroup) {
+ clickDescriptionView(viewGroup);
+ }
+ // In some videos, descriptionView is placed as the third child of recyclerView.
+ if (recyclerView.getChildAt(2) instanceof ViewGroup viewGroup) {
+ clickDescriptionView(viewGroup);
+ }
+ // Even if both methods are performed, there is no major issue with the operation of the patch.
+ } catch (Exception ex) {
+ Logger.printException(() -> "onVideoDescriptionCreate failed.", ex);
+ }
+ });
+ }
+
+ private static void clickDescriptionView(@NonNull ViewGroup descriptionViewGroup) {
+ final View descriptionView = descriptionViewGroup.getChildAt(0);
+ if (descriptionView == null) {
+ return;
+ }
+ // This method is sometimes used multiple times.
+ // To prevent this, ignore method reuse within 1 second.
+ final long now = System.currentTimeMillis();
+ if (now - lastTimeDescriptionViewInvoked < 1000) {
+ return;
+ }
+ lastTimeDescriptionViewInvoked = now;
+
+ // The type of descriptionView can be either ViewGroup or TextView. (A/B tests)
+ // If the type of descriptionView is TextView, longer delay is required.
+ final long delayMillis = descriptionView instanceof TextView
+ ? 500
+ : 100;
+
+ Utils.runOnMainThreadDelayed(() -> Utils.clickView(descriptionView), delayMillis);
+ }
+
+ /**
+ * This method is invoked only when the view type of descriptionView is {@link TextView}. (A/B tests)
+ *
+ * @param textView descriptionView.
+ * @param original Whether to apply {@link TextView#setTextIsSelectable}.
+ * Patch replaces the {@link TextView#setTextIsSelectable} method invoke.
+ */
+ public static void disableVideoDescriptionInteraction(TextView textView, boolean original) {
+ if (textView != null) {
+ textView.setTextIsSelectable(
+ !Settings.DISABLE_VIDEO_DESCRIPTION_INTERACTION.get() && original
+ );
+ }
+ }
+
+ // endregion
+
+ // region [Disable haptic feedback] patch
+
+ public static boolean disableChapterVibrate() {
+ return Settings.DISABLE_HAPTIC_FEEDBACK_CHAPTERS.get();
+ }
+
+
+ public static boolean disableSeekVibrate() {
+ return Settings.DISABLE_HAPTIC_FEEDBACK_SEEK.get();
+ }
+
+ public static boolean disableSeekUndoVibrate() {
+ return Settings.DISABLE_HAPTIC_FEEDBACK_SEEK_UNDO.get();
+ }
+
+ public static boolean disableScrubbingVibrate() {
+ return Settings.DISABLE_HAPTIC_FEEDBACK_SCRUBBING.get();
+ }
+
+ public static boolean disableZoomVibrate() {
+ return Settings.DISABLE_HAPTIC_FEEDBACK_ZOOM.get();
+ }
+
+ // endregion
+
+ // region [Fullscreen components] patch
+
+ public static void disableEngagementPanels(CoordinatorLayout coordinatorLayout) {
+ if (!Settings.DISABLE_ENGAGEMENT_PANEL.get()) return;
+ coordinatorLayout.setVisibility(View.GONE);
+ }
+
+ public static void showVideoTitleSection(FrameLayout frameLayout, View view) {
+ final boolean isEnabled = Settings.SHOW_VIDEO_TITLE_SECTION.get() || !Settings.DISABLE_ENGAGEMENT_PANEL.get();
+
+ if (isEnabled) {
+ frameLayout.addView(view);
+ }
+ }
+
+ public static boolean hideAutoPlayPreview() {
+ return Settings.HIDE_AUTOPLAY_PREVIEW.get();
+ }
+
+ public static boolean hideRelatedVideoOverlay() {
+ return Settings.HIDE_RELATED_VIDEO_OVERLAY.get();
+ }
+
+ public static void hideQuickActions(View view) {
+ final boolean isEnabled = Settings.DISABLE_ENGAGEMENT_PANEL.get() || Settings.HIDE_QUICK_ACTIONS.get();
+
+ Utils.hideViewBy0dpUnderCondition(
+ isEnabled,
+ view
+ );
+ }
+
+ public static void setQuickActionMargin(View view) {
+ int topMarginPx = getQuickActionsTopMargin();
+ if (topMarginPx == 0) {
+ return;
+ }
+
+ if (!(view.getLayoutParams() instanceof ViewGroup.MarginLayoutParams mlp))
+ return;
+
+ mlp.setMargins(
+ mlp.leftMargin,
+ topMarginPx,
+ mlp.rightMargin,
+ mlp.bottomMargin
+ );
+ view.requestLayout();
+ }
+
+ public static boolean enableCompactControlsOverlay(boolean original) {
+ return Settings.ENABLE_COMPACT_CONTROLS_OVERLAY.get() || original;
+ }
+
+ public static boolean disableLandScapeMode(boolean original) {
+ return Settings.DISABLE_LANDSCAPE_MODE.get() || original;
+ }
+
+ private static volatile boolean isScreenOn;
+
+ public static boolean keepFullscreen(boolean original) {
+ if (!Settings.KEEP_LANDSCAPE_MODE.get())
+ return original;
+
+ return isScreenOn;
+ }
+
+ public static void setScreenOn() {
+ if (!Settings.KEEP_LANDSCAPE_MODE.get())
+ return;
+
+ isScreenOn = true;
+ Utils.runOnMainThreadDelayed(() -> isScreenOn = false, Settings.KEEP_LANDSCAPE_MODE_TIMEOUT.get());
+ }
+
+ private static WeakReference watchDescriptorActivityRef = new WeakReference<>(null);
+ private static volatile boolean isLandScapeVideo = true;
+
+ public static void setWatchDescriptorActivity(Activity activity) {
+ watchDescriptorActivityRef = new WeakReference<>(activity);
+ }
+
+ public static boolean forceFullscreen(boolean original) {
+ if (!Settings.FORCE_FULLSCREEN.get())
+ return original;
+
+ Utils.runOnMainThreadDelayed(PlayerPatch::setOrientation, 1000);
+ return true;
+ }
+
+ private static void setOrientation() {
+ final Activity watchDescriptorActivity = watchDescriptorActivityRef.get();
+ final int requestedOrientation = isLandScapeVideo
+ ? ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
+ : watchDescriptorActivity.getRequestedOrientation();
+
+ watchDescriptorActivity.setRequestedOrientation(requestedOrientation);
+ }
+
+ public static void setVideoPortrait(int width, int height) {
+ if (!Settings.FORCE_FULLSCREEN.get())
+ return;
+
+ isLandScapeVideo = width > height;
+ }
+
+ // endregion
+
+ // region [Hide comments component] patch
+
+ public static void changeEmojiPickerOpacity(ImageView imageView) {
+ if (!Settings.HIDE_COMMENT_TIMESTAMP_AND_EMOJI_BUTTONS.get())
+ return;
+
+ imageView.setImageAlpha(0);
+ }
+
+ @Nullable
+ public static Object disableEmojiPickerOnClickListener(@Nullable Object object) {
+ return Settings.HIDE_COMMENT_TIMESTAMP_AND_EMOJI_BUTTONS.get() ? null : object;
+ }
+
+ // endregion
+
+ // region [Hide player buttons] patch
+
+ public static boolean hideAutoPlayButton() {
+ return Settings.HIDE_PLAYER_AUTOPLAY_BUTTON.get();
+ }
+
+ public static boolean hideCaptionsButton(boolean original) {
+ return !Settings.HIDE_PLAYER_CAPTIONS_BUTTON.get() && original;
+ }
+
+ public static int hideCastButton(int original) {
+ return Settings.HIDE_PLAYER_CAST_BUTTON.get()
+ ? View.GONE
+ : original;
+ }
+
+ public static void hideCaptionsButton(View view) {
+ Utils.hideViewUnderCondition(Settings.HIDE_PLAYER_CAPTIONS_BUTTON, view);
+ }
+
+ public static void hideCollapseButton(ImageView imageView) {
+ if (!Settings.HIDE_PLAYER_COLLAPSE_BUTTON.get())
+ return;
+
+ imageView.setImageResource(android.R.color.transparent);
+ imageView.setImageAlpha(0);
+ imageView.setEnabled(false);
+
+ var layoutParams = imageView.getLayoutParams();
+ if (layoutParams instanceof RelativeLayout.LayoutParams) {
+ RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(0, 0);
+ imageView.setLayoutParams(lp);
+ } else {
+ Logger.printDebug(() -> "Unknown collapse button layout params: " + layoutParams);
+ }
+ }
+
+ public static void setTitleAnchorStartMargin(View titleAnchorView) {
+ if (!Settings.HIDE_PLAYER_COLLAPSE_BUTTON.get())
+ return;
+
+ var layoutParams = titleAnchorView.getLayoutParams();
+ if (titleAnchorView.getLayoutParams() instanceof RelativeLayout.LayoutParams lp) {
+ lp.setMarginStart(0);
+ } else {
+ Logger.printDebug(() -> "Unknown title anchor layout params: " + layoutParams);
+ }
+ }
+
+ public static ImageView hideFullscreenButton(ImageView imageView) {
+ final boolean hideView = Settings.HIDE_PLAYER_FULLSCREEN_BUTTON.get();
+
+ Utils.hideViewUnderCondition(hideView, imageView);
+ return hideView ? null : imageView;
+ }
+
+ public static boolean hidePreviousNextButton(boolean previousOrNextButtonVisible) {
+ return !Settings.HIDE_PLAYER_PREVIOUS_NEXT_BUTTON.get() && previousOrNextButtonVisible;
+ }
+
+ public static boolean hideMusicButton() {
+ return Settings.HIDE_PLAYER_YOUTUBE_MUSIC_BUTTON.get();
+ }
+
+ // endregion
+
+ // region [Player components] patch
+
+ public static void changeOpacity(ImageView imageView) {
+ imageView.setImageAlpha(PLAYER_OVERLAY_OPACITY_LEVEL);
+ }
+
+ private static boolean isAutoPopupPanel;
+
+ public static boolean disableAutoPlayerPopupPanels(boolean isLiveChatOrPlaylistPanel) {
+ if (!Settings.DISABLE_AUTO_PLAYER_POPUP_PANELS.get()) {
+ return false;
+ }
+ if (isLiveChatOrPlaylistPanel) {
+ return true;
+ }
+ return isAutoPopupPanel && ShortsPlayerState.getCurrent().isClosed();
+ }
+
+ public static void setInitVideoPanel(boolean initVideoPanel) {
+ isAutoPopupPanel = initVideoPanel;
+ }
+
+ @NonNull
+ public static String videoId = "";
+
+ public static void disableAutoSwitchMixPlaylists(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName,
+ @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle,
+ final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) {
+ if (!Settings.DISABLE_AUTO_SWITCH_MIX_PLAYLISTS.get()) {
+ return;
+ }
+ if (PlayerType.getCurrent() == PlayerType.INLINE_MINIMAL) {
+ return;
+ }
+ if (Objects.equals(newlyLoadedVideoId, videoId)) {
+ return;
+ }
+ videoId = newlyLoadedVideoId;
+
+ if (!VideoInformation.lastPlayerResponseIsAutoGeneratedMixPlaylist()) {
+ return;
+ }
+ VideoUtils.pauseMedia();
+ VideoUtils.openVideo(videoId);
+ }
+
+ public static boolean disableSpeedOverlay() {
+ return disableSpeedOverlay(true);
+ }
+
+ public static boolean disableSpeedOverlay(boolean original) {
+ return !Settings.DISABLE_SPEED_OVERLAY.get() && original;
+ }
+
+ public static double speedOverlayValue() {
+ return speedOverlayValue(2.0f);
+ }
+
+ public static float speedOverlayValue(float original) {
+ return SPEED_OVERLAY_VALUE;
+ }
+
+ public static boolean hideChannelWatermark(boolean original) {
+ return !Settings.HIDE_CHANNEL_WATERMARK.get() && original;
+ }
+
+ public static void hideCrowdfundingBox(View view) {
+ hideViewBy0dpUnderCondition(Settings.HIDE_CROWDFUNDING_BOX.get(), view);
+ }
+
+ public static void hideDoubleTapOverlayFilter(View view) {
+ hideViewByRemovingFromParentUnderCondition(Settings.HIDE_DOUBLE_TAP_OVERLAY_FILTER, view);
+ }
+
+ public static void hideEndScreenCards(View view) {
+ if (Settings.HIDE_END_SCREEN_CARDS.get()) {
+ view.setVisibility(View.GONE);
+ }
+ }
+
+ public static boolean hideFilmstripOverlay() {
+ return Settings.HIDE_FILMSTRIP_OVERLAY.get();
+ }
+
+ public static boolean hideInfoCard(boolean original) {
+ return !Settings.HIDE_INFO_CARDS.get() && original;
+ }
+
+ public static boolean hideSeekMessage() {
+ return Settings.HIDE_SEEK_MESSAGE.get();
+ }
+
+ public static boolean hideSeekUndoMessage() {
+ return Settings.HIDE_SEEK_UNDO_MESSAGE.get();
+ }
+
+ public static void hideSuggestedActions(View view) {
+ hideViewUnderCondition(Settings.HIDE_SUGGESTED_ACTION.get(), view);
+ }
+
+ public static boolean hideSuggestedVideoEndScreen() {
+ return Settings.HIDE_SUGGESTED_VIDEO_END_SCREEN.get();
+ }
+
+ public static void skipAutoPlayCountdown(View view) {
+ if (!hideSuggestedVideoEndScreen())
+ return;
+ if (!Settings.SKIP_AUTOPLAY_COUNTDOWN.get())
+ return;
+
+ Utils.clickView(view);
+ }
+
+ public static boolean hideZoomOverlay() {
+ return Settings.HIDE_ZOOM_OVERLAY.get();
+ }
+
+ // endregion
+
+ // region [Hide player flyout menu] patch
+
+ private static final String QUALITY_LABEL_PREMIUM = "1080p Premium";
+
+ public static String hidePlayerFlyoutMenuEnhancedBitrate(String qualityLabel) {
+ return Settings.HIDE_PLAYER_FLYOUT_MENU_ENHANCED_BITRATE.get() &&
+ Objects.equals(QUALITY_LABEL_PREMIUM, qualityLabel)
+ ? null
+ : qualityLabel;
+ }
+
+ public static void hidePlayerFlyoutMenuCaptionsFooter(View view) {
+ Utils.hideViewUnderCondition(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_CAPTIONS_FOOTER.get(),
+ view
+ );
+ }
+
+ public static void hidePlayerFlyoutMenuQualityFooter(View view) {
+ Utils.hideViewUnderCondition(
+ Settings.HIDE_PLAYER_FLYOUT_MENU_QUALITY_FOOTER.get(),
+ view
+ );
+ }
+
+ public static View hidePlayerFlyoutMenuQualityHeader(View view) {
+ return Settings.HIDE_PLAYER_FLYOUT_MENU_QUALITY_HEADER.get()
+ ? new View(view.getContext()) // empty view
+ : view;
+ }
+
+ /**
+ * Overriding this values is possible only after the litho component has been loaded.
+ * Otherwise, crash will occur.
+ * See {@link InitializationPatch#onCreate}.
+ *
+ * @param original original value.
+ * @return whether to enable PiP Mode in the player flyout menu.
+ */
+ public static boolean hidePiPModeMenu(boolean original) {
+ if (!BaseSettings.SETTINGS_INITIALIZED.get()) {
+ return original;
+ }
+
+ return !Settings.HIDE_PLAYER_FLYOUT_MENU_PIP.get();
+ }
+
+ // endregion
+
+ // region [Seekbar components] patch
+
+ public static final int ORIGINAL_SEEKBAR_COLOR = 0xFFFF0000;
+
+ public static String appendTimeStampInformation(String original) {
+ if (!Settings.APPEND_TIME_STAMP_INFORMATION.get()) return original;
+
+ String appendString = Settings.APPEND_TIME_STAMP_INFORMATION_TYPE.get()
+ ? VideoUtils.getFormattedQualityString(null)
+ : VideoUtils.getFormattedSpeedString(null);
+
+ // Encapsulate the entire appendString with bidi control characters
+ appendString = "\u2066" + appendString + "\u2069";
+
+ // Format the original string with the appended timestamp information
+ return String.format(
+ "%s\u2009•\u2009%s", // Add the separator and the appended information
+ original, appendString
+ );
+ }
+
+ public static void setContainerClickListener(View view) {
+ if (!Settings.APPEND_TIME_STAMP_INFORMATION.get())
+ return;
+
+ if (!(view.getParent() instanceof View containerView))
+ return;
+
+ final BooleanSetting appendTypeSetting = Settings.APPEND_TIME_STAMP_INFORMATION_TYPE;
+ final boolean previousBoolean = appendTypeSetting.get();
+
+ containerView.setOnLongClickListener(timeStampContainerView -> {
+ appendTypeSetting.save(!previousBoolean);
+ return true;
+ }
+ );
+
+ if (Settings.REPLACE_TIME_STAMP_ACTION.get()) {
+ containerView.setOnClickListener(timeStampContainerView -> VideoUtils.showFlyoutMenu());
+ }
+ }
+
+ public static int getSeekbarClickedColorValue(final int colorValue) {
+ return colorValue == ORIGINAL_SEEKBAR_COLOR
+ ? overrideSeekbarColor(colorValue)
+ : colorValue;
+ }
+
+ public static int resumedProgressBarColor(final int colorValue) {
+ return Settings.ENABLE_CUSTOM_SEEKBAR_COLOR.get()
+ ? getSeekbarClickedColorValue(colorValue)
+ : colorValue;
+ }
+
+ /**
+ * Overrides all drawable color that use the YouTube seekbar color.
+ * Used only for the video thumbnails seekbar.
+ *
+ * If {@link Settings#HIDE_SEEKBAR_THUMBNAIL} is enabled, this returns a fully transparent color.
+ */
+ public static int getColor(int colorValue) {
+ if (colorValue == ORIGINAL_SEEKBAR_COLOR) {
+ if (Settings.HIDE_SEEKBAR_THUMBNAIL.get()) {
+ return 0x00000000;
+ }
+ return overrideSeekbarColor(ORIGINAL_SEEKBAR_COLOR);
+ }
+ return colorValue;
+ }
+
+ /**
+ * Points where errors occur when playing videos on the PlayStore (ROOT Build)
+ */
+ public static int overrideSeekbarColor(final int colorValue) {
+ try {
+ return Settings.ENABLE_CUSTOM_SEEKBAR_COLOR.get()
+ ? Color.parseColor(Settings.ENABLE_CUSTOM_SEEKBAR_COLOR_VALUE.get())
+ : colorValue;
+ } catch (Exception ignored) {
+ Settings.ENABLE_CUSTOM_SEEKBAR_COLOR_VALUE.resetToDefault();
+ }
+ return colorValue;
+ }
+
+ public static boolean enableSeekbarTapping() {
+ return Settings.ENABLE_SEEKBAR_TAPPING.get();
+ }
+
+ public static boolean enableHighQualityFullscreenThumbnails() {
+ return Settings.RESTORE_OLD_SEEKBAR_THUMBNAILS.get();
+ }
+
+ private static final int timeBarChapterViewId =
+ ResourceUtils.getIdIdentifier("time_bar_chapter_title");
+
+ public static boolean hideSeekbar() {
+ return Settings.HIDE_SEEKBAR.get();
+ }
+
+ public static boolean disableSeekbarChapters() {
+ return Settings.DISABLE_SEEKBAR_CHAPTERS.get();
+ }
+
+ public static boolean hideSeekbarChapterLabel(View view) {
+ return Settings.HIDE_SEEKBAR_CHAPTER_LABEL.get() && view.getId() == timeBarChapterViewId;
+ }
+
+ public static boolean hideTimeStamp() {
+ return Settings.HIDE_TIME_STAMP.get();
+ }
+
+ public static boolean restoreOldSeekbarThumbnails() {
+ return !Settings.RESTORE_OLD_SEEKBAR_THUMBNAILS.get();
+ }
+
+ public static boolean enableCairoSeekbar() {
+ return Settings.ENABLE_CAIRO_SEEKBAR.get();
+ }
+
+ // endregion
+
+ public static int getQuickActionsTopMargin() {
+ if (!PatchStatus.QuickActions()) {
+ return 0;
+ }
+ return QUICK_ACTIONS_MARGIN_TOP;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/AnimationFeedbackPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/AnimationFeedbackPatch.java
new file mode 100644
index 0000000000..e21d61a0bd
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/AnimationFeedbackPatch.java
@@ -0,0 +1,84 @@
+package app.revanced.extension.youtube.patches.shorts;
+
+import static app.revanced.extension.shared.utils.ResourceUtils.getRawIdentifier;
+import static app.revanced.extension.youtube.patches.shorts.AnimationFeedbackPatch.AnimationType.ORIGINAL;
+
+import androidx.annotation.Nullable;
+
+import com.airbnb.lottie.LottieAnimationView;
+
+import app.revanced.extension.shared.utils.ResourceUtils;
+import app.revanced.extension.youtube.patches.utils.LottieAnimationViewPatch;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class AnimationFeedbackPatch {
+
+ public enum AnimationType {
+ /**
+ * Unmodified type, and same as un-patched.
+ */
+ ORIGINAL(null),
+ THUMBS_UP("like_tap_feedback"),
+ THUMBS_UP_CAIRO("like_tap_feedback_cairo"),
+ HEART("like_tap_feedback_heart"),
+ HEART_TINT("like_tap_feedback_heart_tint"),
+ HIDDEN("like_tap_feedback_hidden");
+
+ /**
+ * Animation id.
+ */
+ final int rawRes;
+
+ AnimationType(@Nullable String jsonName) {
+ this.rawRes = jsonName != null
+ ? getRawIdentifier(jsonName)
+ : 0;
+ }
+ }
+
+ private static final AnimationType CURRENT_TYPE = Settings.ANIMATION_TYPE.get();
+
+ private static final boolean HIDE_PLAY_PAUSE_FEEDBACK = Settings.HIDE_SHORTS_PLAY_PAUSE_BUTTON_BACKGROUND.get();
+
+ private static final int PAUSE_TAP_FEEDBACK_HIDDEN
+ = ResourceUtils.getRawIdentifier("pause_tap_feedback_hidden");
+
+ private static final int PLAY_TAP_FEEDBACK_HIDDEN
+ = ResourceUtils.getRawIdentifier("play_tap_feedback_hidden");
+
+
+ /**
+ * Injection point.
+ */
+ public static void setShortsLikeFeedback(LottieAnimationView lottieAnimationView) {
+ if (CURRENT_TYPE == ORIGINAL) {
+ return;
+ }
+
+ LottieAnimationViewPatch.setLottieAnimationRawResources(lottieAnimationView, CURRENT_TYPE.rawRes);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void setShortsPauseFeedback(LottieAnimationView lottieAnimationView) {
+ if (!HIDE_PLAY_PAUSE_FEEDBACK) {
+ return;
+ }
+
+ LottieAnimationViewPatch.setLottieAnimationRawResources(lottieAnimationView, PAUSE_TAP_FEEDBACK_HIDDEN);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void setShortsPlayFeedback(LottieAnimationView lottieAnimationView) {
+ if (!HIDE_PLAY_PAUSE_FEEDBACK) {
+ return;
+ }
+
+ LottieAnimationViewPatch.setLottieAnimationRawResources(lottieAnimationView, PLAY_TAP_FEEDBACK_HIDDEN);
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/ShortsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/ShortsPatch.java
new file mode 100644
index 0000000000..1bf0f5bc5d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/shorts/ShortsPatch.java
@@ -0,0 +1,224 @@
+package app.revanced.extension.youtube.patches.shorts;
+
+import static app.revanced.extension.shared.utils.Utils.hideViewUnderCondition;
+import static app.revanced.extension.youtube.utils.ExtendedUtils.validateValue;
+
+import android.util.TypedValue;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+
+import com.google.android.libraries.youtube.rendering.ui.pivotbar.PivotBar;
+
+import java.lang.ref.WeakReference;
+
+import app.revanced.extension.shared.utils.ResourceUtils;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.ShortsPlayerState;
+import app.revanced.extension.youtube.utils.VideoUtils;
+
+@SuppressWarnings("unused")
+public class ShortsPatch {
+ private static final boolean ENABLE_TIME_STAMP = Settings.ENABLE_TIME_STAMP.get();
+ public static final boolean HIDE_SHORTS_NAVIGATION_BAR = Settings.HIDE_SHORTS_NAVIGATION_BAR.get();
+
+ private static final int META_PANEL_BOTTOM_MARGIN;
+ private static final double NAVIGATION_BAR_HEIGHT_PERCENTAGE;
+
+ static {
+ if (HIDE_SHORTS_NAVIGATION_BAR) {
+ ShortsPlayerState.getOnChange().addObserver((ShortsPlayerState state) -> {
+ setNavigationBarLayoutParams(state);
+ return null;
+ });
+ }
+ final int bottomMargin = validateValue(
+ Settings.META_PANEL_BOTTOM_MARGIN,
+ 0,
+ 64,
+ "revanced_shorts_meta_panel_bottom_margin_invalid_toast"
+ );
+
+ META_PANEL_BOTTOM_MARGIN = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, (float) bottomMargin, Utils.getResources().getDisplayMetrics());
+
+ final int heightPercentage = validateValue(
+ Settings.SHORTS_NAVIGATION_BAR_HEIGHT_PERCENTAGE,
+ 0,
+ 100,
+ "revanced_shorts_navigation_bar_height_percentage_invalid_toast"
+ );
+
+ NAVIGATION_BAR_HEIGHT_PERCENTAGE = heightPercentage / 100d;
+ }
+
+ public static Enum> repeat;
+ public static Enum> singlePlay;
+ public static Enum> endScreen;
+
+ public static Enum> changeShortsRepeatState(Enum> currentState) {
+ switch (Settings.CHANGE_SHORTS_REPEAT_STATE.get()) {
+ case 1 -> currentState = repeat;
+ case 2 -> currentState = singlePlay;
+ case 3 -> currentState = endScreen;
+ }
+
+ return currentState;
+ }
+
+ public static boolean disableResumingStartupShortsPlayer() {
+ return Settings.DISABLE_RESUMING_SHORTS_PLAYER.get();
+ }
+
+ public static boolean enableShortsTimeStamp(boolean original) {
+ return ENABLE_TIME_STAMP || original;
+ }
+
+ public static int enableShortsTimeStamp(int original) {
+ return ENABLE_TIME_STAMP ? 10010 : original;
+ }
+
+ public static void setShortsMetaPanelBottomMargin(View view) {
+ if (!ENABLE_TIME_STAMP)
+ return;
+
+ if (!(view.getLayoutParams() instanceof RelativeLayout.LayoutParams lp))
+ return;
+
+ lp.setMargins(0, 0, 0, META_PANEL_BOTTOM_MARGIN);
+ lp.setMarginEnd(ResourceUtils.getDimension("reel_player_right_dyn_bar_width"));
+ }
+
+ public static void setShortsTimeStampChangeRepeatState(View view) {
+ if (!ENABLE_TIME_STAMP)
+ return;
+ if (!Settings.TIME_STAMP_CHANGE_REPEAT_STATE.get())
+ return;
+ if (view == null)
+ return;
+
+ view.setLongClickable(true);
+ view.setOnLongClickListener(view1 -> {
+ VideoUtils.showShortsRepeatDialog(view1.getContext());
+ return true;
+ });
+ }
+
+ public static void hideShortsCommentsButton(View view) {
+ hideViewUnderCondition(Settings.HIDE_SHORTS_COMMENTS_BUTTON.get(), view);
+ }
+
+ public static boolean hideShortsDislikeButton() {
+ return Settings.HIDE_SHORTS_DISLIKE_BUTTON.get();
+ }
+
+ public static ViewGroup hideShortsInfoPanel(ViewGroup viewGroup) {
+ return Settings.HIDE_SHORTS_INFO_PANEL.get() ? null : viewGroup;
+ }
+
+ public static boolean hideShortsLikeButton() {
+ return Settings.HIDE_SHORTS_LIKE_BUTTON.get();
+ }
+
+ public static boolean hideShortsPaidPromotionLabel() {
+ return Settings.HIDE_SHORTS_PAID_PROMOTION_LABEL.get();
+ }
+
+ public static void hideShortsPaidPromotionLabel(TextView textView) {
+ hideViewUnderCondition(Settings.HIDE_SHORTS_PAID_PROMOTION_LABEL.get(), textView);
+ }
+
+ public static void hideShortsRemixButton(View view) {
+ hideViewUnderCondition(Settings.HIDE_SHORTS_REMIX_BUTTON.get(), view);
+ }
+
+ public static void hideShortsShareButton(View view) {
+ hideViewUnderCondition(Settings.HIDE_SHORTS_SHARE_BUTTON.get(), view);
+ }
+
+ public static boolean hideShortsSoundButton() {
+ return Settings.HIDE_SHORTS_SOUND_BUTTON.get();
+ }
+
+ private static final int zeroPaddingDimenId =
+ ResourceUtils.getDimenIdentifier("revanced_zero_padding");
+
+ public static int getShortsSoundButtonDimenId(int dimenId) {
+ return Settings.HIDE_SHORTS_SOUND_BUTTON.get()
+ ? zeroPaddingDimenId
+ : dimenId;
+ }
+
+ public static int hideShortsSubscribeButton(int original) {
+ return Settings.HIDE_SHORTS_SUBSCRIBE_BUTTON.get() ? 0 : original;
+ }
+
+ // YouTube 18.29.38 ~ YouTube 19.28.42
+ public static boolean hideShortsPausedHeader() {
+ return Settings.HIDE_SHORTS_PAUSED_HEADER.get();
+ }
+
+ // YouTube 19.29.42 ~
+ public static boolean hideShortsPausedHeader(boolean original) {
+ return Settings.HIDE_SHORTS_PAUSED_HEADER.get() || original;
+ }
+
+ public static boolean hideShortsToolBar(boolean original) {
+ return !Settings.HIDE_SHORTS_TOOLBAR.get() && original;
+ }
+
+ /**
+ * BottomBarContainer is the parent view of {@link PivotBar},
+ * And can be hidden using {@link View#setVisibility} only when it is initialized.
+ *
+ * If it was not hidden with {@link View#setVisibility} when it was initialized,
+ * it should be hidden with {@link FrameLayout.LayoutParams}.
+ *
+ * When Shorts is opened, {@link FrameLayout.LayoutParams} should be changed to 0dp,
+ * When Shorts is closed, {@link FrameLayout.LayoutParams} should be changed to the original.
+ */
+ private static WeakReference bottomBarContainerRef = new WeakReference<>(null);
+
+ private static FrameLayout.LayoutParams originalLayoutParams;
+ private static final FrameLayout.LayoutParams zeroLayoutParams =
+ new FrameLayout.LayoutParams(0, 0);
+
+ public static void setNavigationBar(View view) {
+ if (!HIDE_SHORTS_NAVIGATION_BAR) {
+ return;
+ }
+ bottomBarContainerRef = new WeakReference<>(view);
+ if (!(view.getLayoutParams() instanceof FrameLayout.LayoutParams lp)) {
+ return;
+ }
+ if (originalLayoutParams == null) {
+ originalLayoutParams = lp;
+ }
+ }
+
+ public static int setNavigationBarHeight(int original) {
+ return HIDE_SHORTS_NAVIGATION_BAR
+ ? (int) Math.round(original * NAVIGATION_BAR_HEIGHT_PERCENTAGE)
+ : original;
+ }
+
+ private static void setNavigationBarLayoutParams(@NonNull ShortsPlayerState shortsPlayerState) {
+ final View navigationBar = bottomBarContainerRef.get();
+ if (navigationBar == null) {
+ return;
+ }
+ if (!(navigationBar.getLayoutParams() instanceof FrameLayout.LayoutParams lp)) {
+ return;
+ }
+ navigationBar.setLayoutParams(
+ shortsPlayerState.isClosed()
+ ? originalLayoutParams
+ : zeroLayoutParams
+ );
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spans/SanitizeVideoSubtitleFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spans/SanitizeVideoSubtitleFilter.java
new file mode 100644
index 0000000000..fc4daf1775
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spans/SanitizeVideoSubtitleFilter.java
@@ -0,0 +1,36 @@
+package app.revanced.extension.youtube.patches.spans;
+
+import android.text.SpannableString;
+
+import app.revanced.extension.shared.patches.spans.Filter;
+import app.revanced.extension.shared.patches.spans.SpanType;
+import app.revanced.extension.shared.patches.spans.StringFilterGroup;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings({"unused", "ConstantValue", "FieldCanBeLocal"})
+public final class SanitizeVideoSubtitleFilter extends Filter {
+
+ public SanitizeVideoSubtitleFilter() {
+ addCallbacks(
+ new StringFilterGroup(
+ Settings.SANITIZE_VIDEO_SUBTITLE,
+ "|video_subtitle.eml|"
+ )
+ );
+ }
+
+ @Override
+ public boolean skip(String conversionContext, SpannableString spannableString, Object span,
+ int start, int end, int flags, boolean isWord, SpanType spanType, StringFilterGroup matchedGroup) {
+ if (isWord) {
+ if (spanType == SpanType.IMAGE) {
+ hideImageSpan(spannableString, start, end, flags);
+ return super.skip(conversionContext, spannableString, span, start, end, flags, isWord, spanType, matchedGroup);
+ } else if (spanType == SpanType.CUSTOM_CHARACTER_STYLE) {
+ hideSpan(spannableString, start, end, flags);
+ return super.skip(conversionContext, spannableString, span, start, end, flags, isWord, spanType, matchedGroup);
+ }
+ }
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spans/SearchLinksFilter.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spans/SearchLinksFilter.java
new file mode 100644
index 0000000000..2e6babc822
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/spans/SearchLinksFilter.java
@@ -0,0 +1,52 @@
+package app.revanced.extension.youtube.patches.spans;
+
+import android.text.SpannableString;
+
+import app.revanced.extension.shared.patches.spans.Filter;
+import app.revanced.extension.shared.patches.spans.SpanType;
+import app.revanced.extension.shared.patches.spans.StringFilterGroup;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings({"unused", "ConstantValue", "FieldCanBeLocal"})
+public final class SearchLinksFilter extends Filter {
+ /**
+ * Located in front of the search icon.
+ */
+ private final String WORD_JOINER_CHARACTER = "\u2060";
+
+ public SearchLinksFilter() {
+ addCallbacks(
+ new StringFilterGroup(
+ Settings.HIDE_COMMENT_HIGHLIGHTED_SEARCH_LINKS,
+ "|comment."
+ )
+ );
+ }
+
+ /**
+ * @return Whether the word contains a search icon or not.
+ */
+ private boolean isSearchLinks(SpannableString original, int end) {
+ String originalString = original.toString();
+ int wordJoinerIndex = originalString.indexOf(WORD_JOINER_CHARACTER);
+ // There may be more than one highlight keyword in the comment.
+ // Check the index of all highlight keywords.
+ while (wordJoinerIndex != -1) {
+ if (end - wordJoinerIndex == 2) return true;
+ wordJoinerIndex = originalString.indexOf(WORD_JOINER_CHARACTER, wordJoinerIndex + 1);
+ }
+ return false;
+ }
+
+ @Override
+ public boolean skip(String conversionContext, SpannableString spannableString, Object span,
+ int start, int end, int flags, boolean isWord, SpanType spanType, StringFilterGroup matchedGroup) {
+ if (isWord && isSearchLinks(spannableString, end)) {
+ if (spanType == SpanType.IMAGE) {
+ hideSpan(spannableString, start, end, flags);
+ }
+ return super.skip(conversionContext, spannableString, span, start, end, flags, isWord, spanType, matchedGroup);
+ }
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/swipe/SwipeControlsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/swipe/SwipeControlsPatch.java
new file mode 100644
index 0000000000..24ee3f4a39
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/swipe/SwipeControlsPatch.java
@@ -0,0 +1,48 @@
+package app.revanced.extension.youtube.patches.swipe;
+
+import android.view.View;
+
+import java.lang.ref.WeakReference;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class SwipeControlsPatch {
+ private static WeakReference fullscreenEngagementOverlayViewRef = new WeakReference<>(null);
+
+ /**
+ * Injection point.
+ */
+ public static boolean disableHDRAutoBrightness() {
+ return Settings.DISABLE_HDR_AUTO_BRIGHTNESS.get();
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean enableSwipeToSwitchVideo() {
+ return Settings.ENABLE_SWIPE_TO_SWITCH_VIDEO.get();
+ }
+
+ /**
+ * Injection point.
+ */
+ public static boolean enableWatchPanelGestures() {
+ return Settings.ENABLE_WATCH_PANEL_GESTURES.get();
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param fullscreenEngagementOverlayView R.layout.fullscreen_engagement_overlay
+ */
+ public static void setFullscreenEngagementOverlayView(View fullscreenEngagementOverlayView) {
+ fullscreenEngagementOverlayViewRef = new WeakReference<>(fullscreenEngagementOverlayView);
+ }
+
+ public static boolean isEngagementOverlayVisible() {
+ final View engagementOverlayView = fullscreenEngagementOverlayViewRef.get();
+ return engagementOverlayView != null && engagementOverlayView.getVisibility() == View.VISIBLE;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/AlwaysRepeatPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/AlwaysRepeatPatch.java
new file mode 100644
index 0000000000..41b8ea4d9b
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/AlwaysRepeatPatch.java
@@ -0,0 +1,29 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import static app.revanced.extension.youtube.utils.VideoUtils.pauseMedia;
+
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.VideoInformation;
+
+@SuppressWarnings("unused")
+public class AlwaysRepeatPatch extends Utils {
+
+ /**
+ * Injection point.
+ *
+ * @return video is repeated.
+ */
+ public static boolean alwaysRepeat() {
+ return alwaysRepeatEnabled() && VideoInformation.overrideVideoTime(0);
+ }
+
+ public static boolean alwaysRepeatEnabled() {
+ final boolean alwaysRepeat = Settings.ALWAYS_REPEAT.get();
+ final boolean alwaysRepeatPause = Settings.ALWAYS_REPEAT_PAUSE.get();
+
+ if (alwaysRepeat && alwaysRepeatPause) pauseMedia();
+ return alwaysRepeat;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/BottomSheetHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/BottomSheetHookPatch.java
new file mode 100644
index 0000000000..b7c5c1c084
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/BottomSheetHookPatch.java
@@ -0,0 +1,21 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import app.revanced.extension.youtube.shared.BottomSheetState;
+
+@SuppressWarnings("unused")
+public class BottomSheetHookPatch {
+ /**
+ * Injection point.
+ */
+ public static void onAttachedToWindow() {
+ BottomSheetState.set(BottomSheetState.OPEN);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void onDetachedFromWindow() {
+ BottomSheetState.set(BottomSheetState.CLOSED);
+ }
+}
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/CastButtonPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/CastButtonPatch.java
new file mode 100644
index 0000000000..bd206dcc96
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/CastButtonPatch.java
@@ -0,0 +1,25 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import android.view.View;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class CastButtonPatch {
+
+ /**
+ * The [Hide cast button] setting is separated into the [Hide cast button in player] setting and the [Hide cast button in toolbar] setting.
+ * Always hide the cast button when both settings are true.
+ *
+ * These two settings belong to different patches, and since the default value for this setting is true,
+ * it is essential to ensure that each patch is included to ensure independent operation.
+ */
+ public static int hideCastButton(int original) {
+ return Settings.HIDE_TOOLBAR_CAST_BUTTON.get()
+ && PatchStatus.ToolBarComponents()
+ && Settings.HIDE_PLAYER_CAST_BUTTON.get()
+ && PatchStatus.PlayerButtons()
+ ? View.GONE
+ : original;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/DoubleBackToClosePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/DoubleBackToClosePatch.java
new file mode 100644
index 0000000000..fdf4ba1630
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/DoubleBackToClosePatch.java
@@ -0,0 +1,66 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import android.app.Activity;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+/**
+ * @noinspection ALL
+ */
+public class DoubleBackToClosePatch {
+ /**
+ * Time between two back button presses
+ */
+ private static final long PRESSED_TIMEOUT_MILLISECONDS = Settings.DOUBLE_BACK_TO_CLOSE_TIMEOUT.get();
+
+ /**
+ * Last time back button was pressed
+ */
+ private static long lastTimeBackPressed = 0;
+
+ /**
+ * State whether scroll position reaches the top
+ */
+ private static boolean isScrollTop = false;
+
+ /**
+ * Detect event when back button is pressed
+ *
+ * @param activity is used when closing the app
+ */
+ public static void closeActivityOnBackPressed(@NonNull Activity activity) {
+ // Check scroll position reaches the top in home feed
+ if (!isScrollTop)
+ return;
+
+ final long currentTime = System.currentTimeMillis();
+
+ // If the time between two back button presses does not reach PRESSED_TIMEOUT_MILLISECONDS,
+ // set lastTimeBackPressed to the current time.
+ if (currentTime - lastTimeBackPressed < PRESSED_TIMEOUT_MILLISECONDS ||
+ PRESSED_TIMEOUT_MILLISECONDS == 0)
+ activity.finish();
+ else
+ lastTimeBackPressed = currentTime;
+ }
+
+ /**
+ * Detect event when ScrollView is created by RecyclerView
+ *
+ * start of ScrollView
+ */
+ public static void onStartScrollView() {
+ isScrollTop = false;
+ }
+
+ /**
+ * Detect event when the scroll position reaches the top by the back button
+ *
+ * stop of ScrollView
+ */
+ public static void onStopScrollView() {
+ isScrollTop = true;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/DrawableColorPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/DrawableColorPatch.java
new file mode 100644
index 0000000000..853779b373
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/DrawableColorPatch.java
@@ -0,0 +1,50 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import app.revanced.extension.shared.utils.ResourceUtils;
+
+@SuppressWarnings("unused")
+public class DrawableColorPatch {
+ private static final int[] WHITE_VALUES = {
+ -1, // comments chip background
+ -394759, // music related results panel background
+ -83886081 // video chapters list background
+ };
+
+ private static final int[] DARK_VALUES = {
+ -14145496, // drawer content view background
+ -14606047, // comments chip background
+ -15198184, // music related results panel background
+ -15790321, // comments chip background (new layout)
+ -98492127 // video chapters list background
+ };
+
+ // background colors
+ private static int whiteColor = 0;
+ private static int blackColor = 0;
+
+ public static int getColor(int originalValue) {
+ if (anyEquals(originalValue, DARK_VALUES)) {
+ return getBlackColor();
+ } else if (anyEquals(originalValue, WHITE_VALUES)) {
+ return getWhiteColor();
+ }
+ return originalValue;
+ }
+
+ private static int getBlackColor() {
+ if (blackColor == 0) blackColor = ResourceUtils.getColor("yt_black1");
+ return blackColor;
+ }
+
+ private static int getWhiteColor() {
+ if (whiteColor == 0) whiteColor = ResourceUtils.getColor("yt_white1");
+ return whiteColor;
+ }
+
+ private static boolean anyEquals(int value, int... of) {
+ for (int v : of) if (value == v) return true;
+ return false;
+ }
+}
+
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/InitializationPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/InitializationPatch.java
new file mode 100644
index 0000000000..4dd5f08215
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/InitializationPatch.java
@@ -0,0 +1,39 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import static app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment.showRestartDialog;
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.shared.utils.Utils.runOnMainThreadDelayed;
+
+import android.app.Activity;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.youtube.utils.ExtendedUtils;
+
+@SuppressWarnings("unused")
+public class InitializationPatch {
+ private static final BooleanSetting SETTINGS_INITIALIZED = BaseSettings.SETTINGS_INITIALIZED;
+
+ /**
+ * Some layouts that depend on litho do not load when the app is first installed.
+ * (Also reproduced on unPatched YouTube)
+ *
+ * To fix this, show the restart dialog when the app is installed for the first time.
+ */
+ public static void onCreate(@NonNull Activity mActivity) {
+ if (SETTINGS_INITIALIZED.get()) {
+ return;
+ }
+ runOnMainThreadDelayed(() -> showRestartDialog(mActivity, str("revanced_extended_restart_first_run"), 3500), 500);
+ runOnMainThreadDelayed(() -> SETTINGS_INITIALIZED.save(true), 1000);
+ }
+
+ public static void setExtendedUtils(@NonNull Activity mActivity) {
+ ExtendedUtils.setApplicationLabel();
+ ExtendedUtils.setSmallestScreenWidthDp();
+ ExtendedUtils.setVersionName();
+ ExtendedUtils.setPlayerFlyoutMenuAdditionalSettings();
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/LockModeStateHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/LockModeStateHookPatch.java
new file mode 100644
index 0000000000..96baec1a18
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/LockModeStateHookPatch.java
@@ -0,0 +1,18 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.shared.LockModeState;
+
+@SuppressWarnings("unused")
+public class LockModeStateHookPatch {
+ /**
+ * Injection point.
+ */
+ public static void setLockModeState(@Nullable Enum> lockModeState) {
+ if (lockModeState == null) return;
+
+ LockModeState.setFromString(lockModeState.name());
+ }
+}
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/LottieAnimationViewPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/LottieAnimationViewPatch.java
new file mode 100644
index 0000000000..68323f843d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/LottieAnimationViewPatch.java
@@ -0,0 +1,25 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import com.airbnb.lottie.LottieAnimationView;
+
+import app.revanced.extension.shared.utils.Logger;
+
+public class LottieAnimationViewPatch {
+
+ public static void setLottieAnimationRawResources(LottieAnimationView lottieAnimationView, int rawRes) {
+ if (lottieAnimationView == null) {
+ Logger.printDebug(() -> "View is null");
+ return;
+ }
+ if (rawRes == 0) {
+ Logger.printDebug(() -> "Resource is not found");
+ return;
+ }
+ setAnimation(lottieAnimationView, rawRes);
+ }
+
+ @SuppressWarnings("unused")
+ private static void setAnimation(LottieAnimationView lottieAnimationView, int rawRes) {
+ // Rest of the implementation added by patch.
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PatchStatus.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PatchStatus.java
new file mode 100644
index 0000000000..309415c0d6
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PatchStatus.java
@@ -0,0 +1,50 @@
+package app.revanced.extension.youtube.patches.utils;
+
+public class PatchStatus {
+
+ public static boolean ImageSearchButton() {
+ // Replace this with true if the Hide image search buttons patch succeeds
+ return false;
+ }
+
+ public static boolean MinimalHeader() {
+ // Replace this with true If the Custom header patch succeeds and the patch option was `youtube_minimal_header`
+ return false;
+ }
+
+ public static boolean PlayerButtons() {
+ // Replace this with true if the Hide player buttons patch succeeds
+ return false;
+ }
+
+ public static boolean QuickActions() {
+ // Replace this with true if the Fullscreen components patch succeeds
+ return false;
+ }
+
+ public static boolean RememberPlaybackSpeed() {
+ // Replace this with true if the Video playback patch succeeds
+ return false;
+ }
+
+ public static boolean SponsorBlock() {
+ // Replace this with true if the SponsorBlock patch succeeds
+ return false;
+ }
+
+ public static boolean ToolBarComponents() {
+ // Replace this with true if the Toolbar components patch succeeds
+ return false;
+ }
+
+ // Modified by a patch. Do not touch.
+ public static String RVXMusicPackageName() {
+ return "com.google.android.apps.youtube.music";
+ }
+
+ // Modified by a patch. Do not touch.
+ public static boolean OldSeekbarThumbnailsDefaultBoolean() {
+ return false;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerControlsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerControlsPatch.java
new file mode 100644
index 0000000000..0fb6115e68
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerControlsPatch.java
@@ -0,0 +1,122 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import static app.revanced.extension.shared.utils.ResourceUtils.getIdIdentifier;
+
+import android.view.View;
+
+import androidx.annotation.NonNull;
+
+import java.lang.ref.WeakReference;
+
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.shared.PlayerControlsVisibility;
+
+/**
+ * @noinspection ALL
+ */
+public class PlayerControlsPatch {
+ private static WeakReference playerOverflowButtonViewRef = new WeakReference<>(null);
+ private static final int playerOverflowButtonId =
+ getIdIdentifier("player_overflow_button");
+
+ /**
+ * Injection point.
+ */
+ public static void initializeBottomControlButton(View bottomControlsViewGroup) {
+ // AlwaysRepeat.initialize(bottomControlsViewGroup);
+ // CopyVideoUrl.initialize(bottomControlsViewGroup);
+ // CopyVideoUrlTimestamp.initialize(bottomControlsViewGroup);
+ // MuteVolume.initialize(bottomControlsViewGroup);
+ // ExternalDownload.initialize(bottomControlsViewGroup);
+ // SpeedDialog.initialize(bottomControlsViewGroup);
+ // TimeOrderedPlaylist.initialize(bottomControlsViewGroup);
+ // Whitelists.initialize(bottomControlsViewGroup);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void initializeTopControlButton(View youtubeControlsLayout) {
+ // CreateSegmentButtonController.initialize(youtubeControlsLayout);
+ // VotingButtonController.initialize(youtubeControlsLayout);
+ }
+
+ /**
+ * Injection point.
+ * Legacy method.
+ *
+ * Player overflow button view does not attach to windows immediately after cold start.
+ * Player overflow button view is not attached to the windows until the user touches the player at least once, and the overlay buttons are hidden until then.
+ * To prevent this, uses the legacy method to show the overlay button until the player overflow button view is attached to the windows.
+ */
+ public static void changeVisibility(boolean showing) {
+ if (playerOverflowButtonViewRef.get() != null) {
+ return;
+ }
+ changeVisibility(showing, false);
+ }
+
+ private static void changeVisibility(boolean showing, boolean animation) {
+ // AlwaysRepeat.changeVisibility(showing, animation);
+ // CopyVideoUrl.changeVisibility(showing, animation);
+ // CopyVideoUrlTimestamp.changeVisibility(showing, animation);
+ // MuteVolume.changeVisibility(showing, animation);
+ // ExternalDownload.changeVisibility(showing, animation);
+ // SpeedDialog.changeVisibility(showing, animation);
+ // TimeOrderedPlaylist.changeVisibility(showing, animation);
+ // Whitelists.changeVisibility(showing, animation);
+
+ // CreateSegmentButtonController.changeVisibility(showing, animation);
+ // VotingButtonController.changeVisibility(showing, animation);
+ }
+
+ /**
+ * Injection point.
+ * New method.
+ *
+ * Show or hide the overlay button when the player overflow button view is visible and hidden, respectively.
+ *
+ * Inject the current view into {@link PlayerControlsPatch#playerOverflowButtonView} to check that the player overflow button view is attached to the window.
+ * From this point on, the legacy method is deprecated.
+ */
+ public static void changeVisibility(boolean showing, boolean animation, @NonNull View view) {
+ if (view.getId() != playerOverflowButtonId) {
+ return;
+ }
+ if (playerOverflowButtonViewRef.get() == null) {
+ Utils.runOnMainThreadDelayed(() -> playerOverflowButtonViewRef = new WeakReference<>(view), 1400);
+ }
+ changeVisibility(showing, animation);
+ }
+
+ /**
+ * Injection point.
+ *
+ * Called whenever a motion event occurs on the player controller.
+ *
+ * When the user touches the player overlay (motion event occurs), the player overlay disappears immediately.
+ * In this case, the overlay buttons should also disappear immediately.
+ *
+ * In other words, this method detects when the player overlay disappears immediately upon the user's touch,
+ * and quickly fades out all overlay buttons.
+ */
+ public static void changeVisibilityNegatedImmediate() {
+ if (PlayerControlsVisibility.getCurrent() == PlayerControlsVisibility.PLAYER_CONTROLS_VISIBILITY_HIDDEN) {
+ changeVisibilityNegatedImmediately();
+ }
+ }
+
+ private static void changeVisibilityNegatedImmediately() {
+ // AlwaysRepeat.changeVisibilityNegatedImmediate();
+ // CopyVideoUrl.changeVisibilityNegatedImmediate();
+ // CopyVideoUrlTimestamp.changeVisibilityNegatedImmediate();
+ // MuteVolume.changeVisibilityNegatedImmediate();
+ // ExternalDownload.changeVisibilityNegatedImmediate();
+ // SpeedDialog.changeVisibilityNegatedImmediate();
+ // TimeOrderedPlaylist.changeVisibilityNegatedImmediate();
+ // Whitelists.changeVisibilityNegatedImmediate();
+
+ // CreateSegmentButtonController.changeVisibilityNegatedImmediate();
+ // VotingButtonController.changeVisibilityNegatedImmediate();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerControlsVisibilityHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerControlsVisibilityHookPatch.java
new file mode 100644
index 0000000000..b710594492
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerControlsVisibilityHookPatch.java
@@ -0,0 +1,18 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.shared.PlayerControlsVisibility;
+
+@SuppressWarnings("unused")
+public class PlayerControlsVisibilityHookPatch {
+ /**
+ * Injection point.
+ */
+ public static void setPlayerControlsVisibility(@Nullable Enum> youTubePlayerControlsVisibility) {
+ if (youTubePlayerControlsVisibility == null) return;
+
+ PlayerControlsVisibility.setFromString(youTubePlayerControlsVisibility.name());
+ }
+}
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerTypeHookPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerTypeHookPatch.java
new file mode 100644
index 0000000000..ea9bd114c6
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/PlayerTypeHookPatch.java
@@ -0,0 +1,52 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.shared.PlayerType;
+import app.revanced.extension.youtube.shared.ShortsPlayerState;
+import app.revanced.extension.youtube.shared.VideoState;
+
+@SuppressWarnings("unused")
+public class PlayerTypeHookPatch {
+ /**
+ * Injection point.
+ */
+ public static void setPlayerType(@Nullable Enum> youTubePlayerType) {
+ if (youTubePlayerType == null) return;
+
+ PlayerType.setFromString(youTubePlayerType.name());
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void setVideoState(@Nullable Enum> youTubeVideoState) {
+ if (youTubeVideoState == null) return;
+
+ VideoState.setFromString(youTubeVideoState.name());
+ }
+
+ /**
+ * Injection point.
+ *
+ * Add a listener to the shorts player overlay View.
+ * Triggered when a shorts player is attached or detached to Windows.
+ *
+ * @param view shorts player overlay (R.id.reel_watch_player).
+ */
+ public static void onShortsCreate(View view) {
+ view.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
+ @Override
+ public void onViewAttachedToWindow(@Nullable View v) {
+ ShortsPlayerState.set(ShortsPlayerState.OPEN);
+ }
+ @Override
+ public void onViewDetachedFromWindow(@Nullable View v) {
+ ShortsPlayerState.set(ShortsPlayerState.CLOSED);
+ }
+ });
+ }
+}
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ProgressBarDrawable.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ProgressBarDrawable.java
new file mode 100644
index 0000000000..3cb20d6179
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ProgressBarDrawable.java
@@ -0,0 +1,46 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import static app.revanced.extension.youtube.patches.player.PlayerPatch.ORIGINAL_SEEKBAR_COLOR;
+import static app.revanced.extension.youtube.patches.player.PlayerPatch.resumedProgressBarColor;
+
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.drawable.Drawable;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class ProgressBarDrawable extends Drawable {
+
+ private final Paint paint = new Paint();
+
+ @Override
+ public void draw(@NonNull Canvas canvas) {
+ if (Settings.HIDE_SEEKBAR_THUMBNAIL.get()) {
+ return;
+ }
+ paint.setColor(resumedProgressBarColor(ORIGINAL_SEEKBAR_COLOR));
+ canvas.drawRect(getBounds(), paint);
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+ paint.setAlpha(alpha);
+ }
+
+ @Override
+ public void setColorFilter(@Nullable ColorFilter colorFilter) {
+ paint.setColorFilter(colorFilter);
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSLUCENT;
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ReturnYouTubeChannelNamePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ReturnYouTubeChannelNamePatch.java
new file mode 100644
index 0000000000..fca94b6b03
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ReturnYouTubeChannelNamePatch.java
@@ -0,0 +1,130 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import androidx.annotation.NonNull;
+
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class ReturnYouTubeChannelNamePatch {
+
+ private static final boolean REPLACE_CHANNEL_HANDLE = Settings.REPLACE_CHANNEL_HANDLE.get();
+ /**
+ * The last character of some handles is an official channel certification mark.
+ * This was in the form of nonBreakSpaceCharacter before SpannableString was made.
+ */
+ private static final String NON_BREAK_SPACE_CHARACTER = "\u00A0";
+ private volatile static String channelName = "";
+
+ /**
+ * Key: channelId, Value: channelName.
+ */
+ private static final Map channelIdMap = Collections.synchronizedMap(
+ new LinkedHashMap<>(20) {
+ private static final int CACHE_LIMIT = 10;
+
+ @Override
+ protected boolean removeEldestEntry(Entry eldest) {
+ return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit.
+ }
+ });
+
+ /**
+ * Key: handle, Value: channelName.
+ */
+ private static final Map channelHandleMap = Collections.synchronizedMap(
+ new LinkedHashMap<>(20) {
+ private static final int CACHE_LIMIT = 10;
+
+ @Override
+ protected boolean removeEldestEntry(Entry eldest) {
+ return size() > CACHE_LIMIT; // Evict the oldest entry if over the cache limit.
+ }
+ });
+
+ /**
+ * This method is only invoked on Shorts and is updated whenever the user swipes up or down on the Shorts.
+ */
+ public static void newShortsVideoStarted(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName,
+ @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle,
+ final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) {
+ if (!REPLACE_CHANNEL_HANDLE) {
+ return;
+ }
+ if (channelIdMap.get(newlyLoadedChannelId) != null) {
+ return;
+ }
+ if (channelIdMap.put(newlyLoadedChannelId, newlyLoadedChannelName) == null) {
+ channelName = newlyLoadedChannelName;
+ Logger.printDebug(() -> "New video started, ChannelId " + newlyLoadedChannelId + ", Channel Name: " + newlyLoadedChannelName);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static CharSequence onCharSequenceLoaded(@NonNull Object conversionContext,
+ @NonNull CharSequence charSequence) {
+ try {
+ if (!REPLACE_CHANNEL_HANDLE) {
+ return charSequence;
+ }
+ final String conversionContextString = conversionContext.toString();
+ if (!conversionContextString.contains("|reel_channel_bar_inner.eml|")) {
+ return charSequence;
+ }
+ final String originalString = charSequence.toString();
+ if (!originalString.startsWith("@")) {
+ return charSequence;
+ }
+ return getChannelName(originalString);
+ } catch (Exception ex) {
+ Logger.printException(() -> "onCharSequenceLoaded failed", ex);
+ }
+ return charSequence;
+ }
+
+ private static CharSequence getChannelName(@NonNull String handle) {
+ final String trimmedHandle = handle.replaceAll(NON_BREAK_SPACE_CHARACTER, "");
+
+ String cachedChannelName = channelHandleMap.get(trimmedHandle);
+ if (cachedChannelName == null) {
+ if (!channelName.isEmpty() && channelHandleMap.put(handle, channelName) == null) {
+ Logger.printDebug(() -> "Set Handle from last fetched Channel Name, Handle: " + handle + ", Channel Name: " + channelName);
+ cachedChannelName = channelName;
+ } else {
+ Logger.printDebug(() -> "Channel handle is not found: " + trimmedHandle);
+ return handle;
+ }
+ }
+
+ if (handle.contains(NON_BREAK_SPACE_CHARACTER)) {
+ cachedChannelName += NON_BREAK_SPACE_CHARACTER;
+ }
+ String replacedChannelName = cachedChannelName;
+ Logger.printDebug(() -> "Replace Handle " + handle + " to " + replacedChannelName);
+ return replacedChannelName;
+ }
+
+ public synchronized static void setLastShortsChannelId(@NonNull String handle, @NonNull String channelId) {
+ try {
+ if (channelHandleMap.get(handle) != null) {
+ return;
+ }
+ final String channelName = channelIdMap.get(channelId);
+ if (channelName == null) {
+ Logger.printDebug(() -> "Channel name is not found!");
+ return;
+ }
+ if (channelHandleMap.put(handle, channelName) == null) {
+ Logger.printDebug(() -> "Set Handle from Shorts, Handle: " + handle + ", Channel Name: " + channelName);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "setLastShortsChannelId failure ", ex);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ReturnYouTubeDislikePatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ReturnYouTubeDislikePatch.java
new file mode 100644
index 0000000000..8e29174e5c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ReturnYouTubeDislikePatch.java
@@ -0,0 +1,690 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import static app.revanced.extension.shared.returnyoutubedislike.ReturnYouTubeDislike.Vote;
+import static app.revanced.extension.youtube.utils.ExtendedUtils.isSpoofingToLessThan;
+
+import android.graphics.Rect;
+import android.graphics.drawable.ShapeDrawable;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.Spanned;
+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.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import app.revanced.extension.shared.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.components.ReturnYouTubeDislikeFilterPatch;
+import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+import app.revanced.extension.youtube.shared.VideoInformation;
+
+/**
+ * Handles all interaction of UI patch components.
+ *
+ * Known limitation:
+ * The implementation of Shorts litho requires blocking the loading the first Short until RYD has completed.
+ * This is because it modifies the dislikes text synchronously, and if the RYD fetch has
+ * not completed yet then the UI will be temporarily frozen.
+ *
+ * A (yet to be implemented) solution that fixes this problem. Any one of:
+ * - Modify patch to hook onto the Shorts Litho TextView, and update the dislikes text asynchronously.
+ * - Find a way to force Litho to rebuild it's component tree,
+ * and use that hook to force the shorts dislikes to update after the fetch is completed.
+ * - Hook into the dislikes button image view, and replace the dislikes thumb down image with a
+ * generated image of the number of dislikes, then update the image asynchronously. This Could
+ * also be used for the regular video player to give a better UI layout and completely remove
+ * the need for the Rolling Number patches.
+ */
+@SuppressWarnings("unused")
+public class ReturnYouTubeDislikePatch {
+
+ public static final boolean IS_SPOOFING_TO_NON_LITHO_SHORTS_PLAYER =
+ isSpoofingToLessThan("18.34.00");
+
+ /**
+ * RYD data for the current video on screen.
+ */
+ @Nullable
+ private static volatile ReturnYouTubeDislike currentVideoData;
+
+ /**
+ * The last litho based Shorts loaded.
+ * May be the same value as {@link #currentVideoData}, but usually is the next short to swipe to.
+ */
+ @Nullable
+ private static volatile ReturnYouTubeDislike lastLithoShortsVideoData;
+
+ /**
+ * Because the litho Shorts spans are created after {@link ReturnYouTubeDislikeFilterPatch}
+ * detects the video ids, after the user votes the litho will update
+ * but {@link #lastLithoShortsVideoData} is not the correct data to use.
+ * If this is true, then instead use {@link #currentVideoData}.
+ */
+ private static volatile boolean lithoShortsShouldUseCurrentData;
+
+ /**
+ * Last video id prefetched. Field is to prevent prefetching the same video id multiple times in a row.
+ */
+ @Nullable
+ private static volatile String lastPrefetchedVideoId;
+
+ public static void onRYDStatusChange() {
+ ReturnYouTubeDislikeApi.resetRateLimits();
+ // Must remove all values to protect against using stale data
+ // if the user enables RYD while a video is on screen.
+ clearData();
+ }
+
+ private static void clearData() {
+ currentVideoData = null;
+ lastLithoShortsVideoData = null;
+ lithoShortsShouldUseCurrentData = false;
+ // Rolling number text should not be cleared,
+ // as it's used if incognito Short is opened/closed
+ // while a regular video is on screen.
+ }
+
+ //
+ // Litho player for both regular videos and Shorts.
+ //
+
+ /**
+ * Injection point.
+ *
+ * For Litho segmented buttons and Litho Shorts player.
+ */
+ @NonNull
+ public static CharSequence onLithoTextLoaded(@NonNull Object conversionContext,
+ @NonNull CharSequence original) {
+ return onLithoTextLoaded(conversionContext, original, false);
+ }
+
+ /**
+ * Injection point.
+ *
+ * Called when a litho text component is initially created,
+ * and also when a Span is later reused again (such as scrolling off/on screen).
+ *
+ * This method is sometimes called on the main thread, but it usually is called _off_ the main thread.
+ * This method can be called multiple times for the same UI element (including after dislikes was added).
+ *
+ * @param original Original char sequence was created or reused by Litho.
+ * @param isRollingNumber If the span is for a Rolling Number.
+ * @return The original char sequence (if nothing should change), or a replacement char sequence that contains dislikes.
+ */
+ @NonNull
+ private static CharSequence onLithoTextLoaded(@NonNull Object conversionContext,
+ @NonNull CharSequence original,
+ boolean isRollingNumber) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) {
+ return original;
+ }
+
+ String conversionContextString = conversionContext.toString();
+
+ if (isRollingNumber && !conversionContextString.contains("video_action_bar.eml")) {
+ return original;
+ }
+
+ if (conversionContextString.contains("segmented_like_dislike_button.eml")) {
+ // Regular video.
+ ReturnYouTubeDislike videoData = currentVideoData;
+ if (videoData == null) {
+ return original; // User enabled RYD while a video was on screen.
+ }
+ if (!(original instanceof Spanned)) {
+ original = new SpannableString(original);
+ }
+ return videoData.getDislikesSpanForRegularVideo((Spanned) original,
+ true, isRollingNumber);
+ }
+
+ if (isRollingNumber) {
+ return original; // No need to check for Shorts in the context.
+ }
+
+ if (conversionContextString.contains("|shorts_dislike_button.eml")) {
+ return getShortsSpan(original, true);
+ }
+
+ if (conversionContextString.contains("|shorts_like_button.eml")
+ && !Utils.containsNumber(original)) {
+ Logger.printDebug(() -> "Replacing hidden likes count");
+ return getShortsSpan(original, false);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onLithoTextLoaded failure", ex);
+ }
+ return original;
+ }
+
+ //
+ // Litho Shorts player in the incognito mode / live stream.
+ //
+
+ /**
+ * Injection point.
+ *
+ * This method is used in the following situations.
+ *
+ * 1. When the dislike counts are fetched in the Incognito mode.
+ * 2. When the dislike counts are fetched in the live stream.
+ *
+ * @param original Original span that was created or reused by Litho.
+ * @return The original span (if nothing should change), or a replacement span that contains dislikes.
+ */
+ public static CharSequence onCharSequenceLoaded(@NonNull Object conversionContext,
+ @NonNull CharSequence original) {
+ try {
+ String conversionContextString = conversionContext.toString();
+ if (!Settings.RYD_ENABLED.get()) {
+ return original;
+ }
+ if (!Settings.RYD_SHORTS.get()) {
+ return original;
+ }
+
+ final boolean fetchDislikeLiveStream =
+ conversionContextString.contains("immersive_live_video_action_bar.eml")
+ && conversionContextString.contains("|dislike_button.eml|");
+
+ if (!fetchDislikeLiveStream) {
+ return original;
+ }
+
+ ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(ReturnYouTubeDislikeFilterPatch.getShortsVideoId());
+ videoData.setVideoIdIsShort(true);
+ lastLithoShortsVideoData = videoData;
+ lithoShortsShouldUseCurrentData = false;
+
+ return videoData.getDislikeSpanForShort(SHORTS_LOADING_SPAN);
+ } catch (Exception ex) {
+ Logger.printException(() -> "onCharSequenceLoaded failure", ex);
+ }
+ return original;
+ }
+
+
+ private static CharSequence getShortsSpan(@NonNull CharSequence original, boolean isDislikesSpan) {
+ // Litho Shorts player.
+ if (!Settings.RYD_SHORTS.get() || (isDislikesSpan && Settings.HIDE_SHORTS_DISLIKE_BUTTON.get())
+ || (!isDislikesSpan && Settings.HIDE_SHORTS_LIKE_BUTTON.get())) {
+ return original;
+ }
+
+ ReturnYouTubeDislike videoData = lastLithoShortsVideoData;
+ if (videoData == null) {
+ // The Shorts litho video id filter did not detect the video id.
+ // This is normal in incognito mode, but otherwise is abnormal.
+ Logger.printDebug(() -> "Cannot modify Shorts litho span, data is null");
+ return original;
+ }
+
+ // Use the correct dislikes data after voting.
+ if (lithoShortsShouldUseCurrentData) {
+ if (isDislikesSpan) {
+ lithoShortsShouldUseCurrentData = false;
+ }
+ videoData = currentVideoData;
+ if (videoData == null) {
+ Logger.printException(() -> "currentVideoData is null"); // Should never happen
+ return original;
+ }
+ Logger.printDebug(() -> "Using current video data for litho span");
+ }
+
+ return isDislikesSpan
+ ? videoData.getDislikeSpanForShort((Spanned) original)
+ : videoData.getLikeSpanForShort((Spanned) original);
+ }
+
+ //
+ // Rolling Number
+ //
+
+ /**
+ * Current regular video rolling number text, if rolling number is in use.
+ * This is saved to a field as it's used in every draw() call.
+ */
+ @Nullable
+ private static volatile CharSequence rollingNumberSpan;
+
+ /**
+ * Injection point.
+ */
+ public static String onRollingNumberLoaded(@NonNull Object conversionContext,
+ @NonNull String original) {
+ try {
+ CharSequence replacement = onLithoTextLoaded(conversionContext, original, true);
+
+ String replacementString = replacement.toString();
+ if (!replacementString.equals(original)) {
+ rollingNumberSpan = replacement;
+ return replacementString;
+ } // Else, the text was not a likes count but instead the view count or something else.
+ } catch (Exception ex) {
+ Logger.printException(() -> "onRollingNumberLoaded failure", ex);
+ }
+ return original;
+ }
+
+ /**
+ * Injection point.
+ *
+ * Called for all usage of Rolling Number.
+ * Modifies the measured String text width to include the left separator and padding, if needed.
+ */
+ public static float onRollingNumberMeasured(String text, float measuredTextWidth) {
+ try {
+ if (Settings.RYD_ENABLED.get()) {
+ if (ReturnYouTubeDislike.isPreviouslyCreatedSegmentedSpan(text)) {
+ // +1 pixel is needed for some foreign languages that measure
+ // the text different from what is used for layout (Greek in particular).
+ // Probably a bug in Android, but who knows.
+ // Single line mode is also used as an additional fix for this issue.
+ if (Settings.RYD_COMPACT_LAYOUT.get()) {
+ return measuredTextWidth + 1;
+ }
+
+ return measuredTextWidth + 1
+ + ReturnYouTubeDislike.leftSeparatorBounds.right
+ + ReturnYouTubeDislike.leftSeparatorShapePaddingPixels;
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onRollingNumberMeasured failure", ex);
+ }
+
+ return measuredTextWidth;
+ }
+
+ /**
+ * Add Rolling Number text view modifications.
+ */
+ private static void addRollingNumberPatchChanges(TextView view) {
+ // YouTube Rolling Numbers do not use compound drawables or drawable padding.
+ if (view.getCompoundDrawablePadding() == 0) {
+ Logger.printDebug(() -> "Adding rolling number TextView changes");
+ view.setCompoundDrawablePadding(ReturnYouTubeDislike.leftSeparatorShapePaddingPixels);
+ ShapeDrawable separator = ReturnYouTubeDislike.getLeftSeparatorDrawable();
+ if (Utils.isRightToLeftTextLayout()) {
+ view.setCompoundDrawables(null, null, separator, null);
+ } else {
+ view.setCompoundDrawables(separator, null, null, null);
+ }
+
+ // Disliking can cause the span to grow in size, which is ok and is laid out correctly,
+ // but if the user then removes their dislike the layout will not adjust to the new shorter width.
+ // Use a center alignment to take up any extra space.
+ view.setTextAlignment(View.TEXT_ALIGNMENT_CENTER);
+
+ // Single line mode does not clip words if the span is larger than the view bounds.
+ // The styled span applied to the view should always have the same bounds,
+ // but use this feature just in case the measurements are somehow off by a few pixels.
+ view.setSingleLine(true);
+ }
+ }
+
+ /**
+ * Remove Rolling Number text view modifications made by this patch.
+ * Required as it appears text views can be reused for other rolling numbers (view count, upload time, etc).
+ */
+ private static void removeRollingNumberPatchChanges(TextView view) {
+ if (view.getCompoundDrawablePadding() != 0) {
+ Logger.printDebug(() -> "Removing rolling number TextView changes");
+ view.setCompoundDrawablePadding(0);
+ view.setCompoundDrawables(null, null, null, null);
+ view.setTextAlignment(View.TEXT_ALIGNMENT_GRAVITY); // Default alignment
+ view.setSingleLine(false);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static CharSequence updateRollingNumber(TextView view, CharSequence original) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) {
+ removeRollingNumberPatchChanges(view);
+ return original;
+ }
+ final boolean isDescriptionPanel = view.getParent() instanceof ViewGroup viewGroupParent
+ && viewGroupParent.getChildCount() < 2;
+ // Called for all instances of RollingNumber, so must check if text is for a dislikes.
+ // Text will already have the correct content but it's missing the drawable separators.
+ if (!ReturnYouTubeDislike.isPreviouslyCreatedSegmentedSpan(original.toString()) || isDescriptionPanel) {
+ // The text is the video view count, upload time, or some other text.
+ removeRollingNumberPatchChanges(view);
+ return original;
+ }
+
+ CharSequence replacement = rollingNumberSpan;
+ if (replacement == null) {
+ // User enabled RYD while a video was open,
+ // or user opened/closed a Short while a regular video was opened.
+ Logger.printDebug(() -> "Cannot update rolling number (field is null)");
+ removeRollingNumberPatchChanges(view);
+ return original;
+ }
+
+ if (Settings.RYD_COMPACT_LAYOUT.get()) {
+ removeRollingNumberPatchChanges(view);
+ } else {
+ addRollingNumberPatchChanges(view);
+ }
+
+ // Remove any padding set by Rolling Number.
+ view.setPadding(0, 0, 0, 0);
+
+ // When displaying dislikes, the rolling animation is not visually correct
+ // and the dislikes always animate (even though the dislike count has not changed).
+ // The animation is caused by an image span attached to the span,
+ // and using only the modified segmented span prevents the animation from showing.
+ return replacement;
+ } catch (Exception ex) {
+ Logger.printException(() -> "updateRollingNumber failure", ex);
+ return original;
+ }
+ }
+
+ //
+ // Non litho Shorts player.
+ //
+
+ /**
+ * Replacement text to use for "Dislikes" while RYD is fetching.
+ */
+ private static final Spannable SHORTS_LOADING_SPAN = new SpannableString("-");
+
+ /**
+ * Dislikes TextViews used by Shorts.
+ *
+ * Multiple TextViews are loaded at once (for the prior and next videos to swipe to).
+ * Keep track of all of them, and later pick out the correct one based on their on screen position.
+ */
+ private static final List> shortsTextViewRefs = new ArrayList<>();
+
+ private static void clearRemovedShortsTextViews() {
+ shortsTextViewRefs.removeIf(ref -> ref.get() == null);
+ }
+
+ /**
+ * Injection point. Called when a Shorts dislike is updated. Always on main thread.
+ * Handles update asynchronously, otherwise Shorts video will be frozen while the UI thread is blocked.
+ *
+ * @return if RYD is enabled and the TextView was updated.
+ */
+ public static boolean setShortsDislikes(@NonNull View likeDislikeView) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) {
+ return false;
+ }
+ if (!Settings.RYD_SHORTS.get() || Settings.HIDE_SHORTS_DISLIKE_BUTTON.get()) {
+ // Must clear the data here, in case a new video was loaded while PlayerType
+ // suggested the video was not a short (can happen when spoofing to an old app version).
+ clearData();
+ return false;
+ }
+ Logger.printDebug(() -> "setShortsDislikes");
+
+ TextView textView = (TextView) likeDislikeView;
+ textView.setText(SHORTS_LOADING_SPAN); // Change 'Dislike' text to the loading text.
+ shortsTextViewRefs.add(new WeakReference<>(textView));
+
+ if (likeDislikeView.isSelected() && isShortTextViewOnScreen(textView)) {
+ Logger.printDebug(() -> "Shorts dislike is already selected");
+ ReturnYouTubeDislike videoData = currentVideoData;
+ if (videoData != null) videoData.setUserVote(Vote.DISLIKE);
+ }
+
+ // For the first short played, the Shorts dislike hook is called after the video id hook.
+ // But for most other times this hook is called before the video id (which is not ideal).
+ // Must update the TextViews here, and also after the videoId changes.
+ updateOnScreenShortsTextViews(false);
+
+ return true;
+ } catch (Exception ex) {
+ Logger.printException(() -> "setShortsDislikes failure", ex);
+ return false;
+ }
+ }
+
+ /**
+ * @param forceUpdate if false, then only update the 'loading text views.
+ * If true, update all on screen text views.
+ */
+ private static void updateOnScreenShortsTextViews(boolean forceUpdate) {
+ try {
+ clearRemovedShortsTextViews();
+ if (shortsTextViewRefs.isEmpty()) {
+ return;
+ }
+ ReturnYouTubeDislike videoData = currentVideoData;
+ if (videoData == null) {
+ return;
+ }
+
+ Logger.printDebug(() -> "updateShortsTextViews");
+
+ Runnable update = () -> {
+ Spanned shortsDislikesSpan = videoData.getDislikeSpanForShort(SHORTS_LOADING_SPAN);
+ Utils.runOnMainThreadNowOrLater(() -> {
+ String videoId = videoData.getVideoId();
+ if (!videoId.equals(VideoInformation.getVideoId())) {
+ // User swiped to new video before fetch completed
+ Logger.printDebug(() -> "Ignoring stale dislikes data for short: " + videoId);
+ return;
+ }
+
+ // Update text views that appear to be visible on screen.
+ // Only 1 will be the actual textview for the current Short,
+ // but discarded and not yet garbage collected views can remain.
+ // So must set the dislike span on all views that match.
+ for (WeakReference textViewRef : shortsTextViewRefs) {
+ TextView textView = textViewRef.get();
+ if (textView == null) {
+ continue;
+ }
+ if (isShortTextViewOnScreen(textView)
+ && (forceUpdate || textView.getText().toString().equals(SHORTS_LOADING_SPAN.toString()))) {
+ Logger.printDebug(() -> "Setting Shorts TextView to: " + shortsDislikesSpan);
+ textView.setText(shortsDislikesSpan);
+ }
+ }
+ });
+ };
+ if (videoData.fetchCompleted()) {
+ update.run(); // Network call is completed, no need to wait on background thread.
+ } else {
+ Utils.runOnBackgroundThread(update);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "updateOnScreenShortsTextViews failure", ex);
+ }
+ }
+
+ /**
+ * Check if a view is within the screen bounds.
+ */
+ private static boolean isShortTextViewOnScreen(@NonNull View view) {
+ final int[] location = new int[2];
+ view.getLocationInWindow(location);
+ if (location[0] <= 0 && location[1] <= 0) { // Lower bound
+ return false;
+ }
+ Rect windowRect = new Rect();
+ view.getWindowVisibleDisplayFrame(windowRect); // Upper bound
+ return location[0] < windowRect.width() && location[1] < windowRect.height();
+ }
+
+
+ //
+ // Video Id and voting hooks (all players).
+ //
+
+ private static volatile boolean lastPlayerResponseWasShort;
+
+ /**
+ * Injection point. Uses 'playback response' video id hook to preload RYD.
+ */
+ public static void preloadVideoId(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) {
+ return;
+ }
+ if (videoId.equals(lastPrefetchedVideoId)) {
+ return;
+ }
+
+ final boolean videoIdIsShort = VideoInformation.lastPlayerResponseIsShort();
+ // Shorts shelf in home and subscription feed causes player response hook to be called,
+ // and the 'is opening/playing' parameter will be false.
+ // This hook will be called again when the Short is actually opened.
+ if (videoIdIsShort && (!isShortAndOpeningOrPlaying || !Settings.RYD_SHORTS.get())) {
+ return;
+ }
+ final boolean waitForFetchToComplete = !IS_SPOOFING_TO_NON_LITHO_SHORTS_PLAYER
+ && videoIdIsShort && !lastPlayerResponseWasShort;
+
+ Logger.printDebug(() -> "Prefetching RYD for video: " + videoId);
+ ReturnYouTubeDislike fetch = ReturnYouTubeDislike.getFetchForVideoId(videoId);
+ if (waitForFetchToComplete && !fetch.fetchCompleted()) {
+ // This call is off the main thread, so wait until the RYD fetch completely finishes,
+ // otherwise if this returns before the fetch completes then the UI can
+ // become frozen when the main thread tries to modify the litho Shorts dislikes and
+ // it must wait for the fetch.
+ // Only need to do this for the first Short opened, as the next Short to swipe to
+ // are preloaded in the background.
+ //
+ // If an asynchronous litho Shorts solution is found, then this blocking call should be removed.
+ Logger.printDebug(() -> "Waiting for prefetch to complete: " + videoId);
+ fetch.getFetchData(20000); // Any arbitrarily large max wait time.
+ }
+
+ // Set the fields after the fetch completes, so any concurrent calls will also wait.
+ lastPlayerResponseWasShort = videoIdIsShort;
+ lastPrefetchedVideoId = videoId;
+ } catch (Exception ex) {
+ Logger.printException(() -> "preloadVideoId failure", ex);
+ }
+ }
+
+ /**
+ * Injection point. Uses 'current playing' video id hook. Always called on main thread.
+ */
+ public static void newVideoLoaded(@NonNull String videoId) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) {
+ return;
+ }
+ Objects.requireNonNull(videoId);
+
+ final PlayerType currentPlayerType = PlayerType.getCurrent();
+ final boolean isNoneHiddenOrSlidingMinimized = currentPlayerType.isNoneHiddenOrSlidingMinimized();
+ if (isNoneHiddenOrSlidingMinimized && !Settings.RYD_SHORTS.get()) {
+ // Must clear here, otherwise the wrong data can be used for a minimized regular video.
+ clearData();
+ return;
+ }
+
+ if (videoIdIsSame(currentVideoData, videoId)) {
+ return;
+ }
+ Logger.printDebug(() -> "New video id: " + videoId + " playerType: " + currentPlayerType);
+
+ ReturnYouTubeDislike data = ReturnYouTubeDislike.getFetchForVideoId(videoId);
+ // Pre-emptively set the data to short status.
+ // Required to prevent Shorts data from being used on a minimized video in incognito mode.
+ if (isNoneHiddenOrSlidingMinimized) {
+ data.setVideoIdIsShort(true);
+ }
+ currentVideoData = data;
+
+ // Current video id hook can be called out of order with the non litho Shorts text view hook.
+ // Must manually update again here.
+ if (isNoneHiddenOrSlidingMinimized) {
+ updateOnScreenShortsTextViews(true);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "newVideoLoaded failure", ex);
+ }
+ }
+
+ public static void setLastLithoShortsVideoId(@Nullable String videoId) {
+ if (videoIdIsSame(lastLithoShortsVideoData, videoId)) {
+ return;
+ }
+
+ if (videoId == null) {
+ // Litho filter did not detect the video id. App is in incognito mode,
+ // or the proto buffer structure was changed and the video id is no longer present.
+ // Must clear both currently playing and last litho data otherwise the
+ // next regular video may use the wrong data.
+ Logger.printDebug(() -> "Litho filter did not find any video ids");
+ clearData();
+ return;
+ }
+
+ Logger.printDebug(() -> "New litho Shorts video id: " + videoId);
+ ReturnYouTubeDislike videoData = ReturnYouTubeDislike.getFetchForVideoId(videoId);
+ videoData.setVideoIdIsShort(true);
+ lastLithoShortsVideoData = videoData;
+ lithoShortsShouldUseCurrentData = false;
+ }
+
+ private static boolean videoIdIsSame(@Nullable ReturnYouTubeDislike fetch, @Nullable String videoId) {
+ return (fetch == null && videoId == null)
+ || (fetch != null && fetch.getVideoId().equals(videoId));
+ }
+
+ /**
+ * Injection point.
+ *
+ * Called when the user likes or dislikes.
+ *
+ * @param vote int that matches {@link Vote#value}
+ */
+ public static void sendVote(int vote) {
+ try {
+ if (!Settings.RYD_ENABLED.get()) {
+ return;
+ }
+ final boolean isNoneHiddenOrMinimized = PlayerType.getCurrent().isNoneHiddenOrMinimized();
+ if (isNoneHiddenOrMinimized && !Settings.RYD_SHORTS.get()) {
+ return;
+ }
+ ReturnYouTubeDislike videoData = currentVideoData;
+ if (videoData == null) {
+ Logger.printDebug(() -> "Cannot send vote, as current video data is null");
+ return; // User enabled RYD while a regular video was minimized.
+ }
+
+ for (Vote v : Vote.values()) {
+ if (v.value == vote) {
+ videoData.sendVote(v);
+
+ if (isNoneHiddenOrMinimized && lastLithoShortsVideoData != null) {
+ lithoShortsShouldUseCurrentData = true;
+ }
+
+ return;
+ }
+ }
+ Logger.printException(() -> "Unknown vote type: " + vote);
+ } catch (Exception ex) {
+ Logger.printException(() -> "sendVote failure", ex);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ToolBarPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ToolBarPatch.java
new file mode 100644
index 0000000000..2d681133f2
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/utils/ToolBarPatch.java
@@ -0,0 +1,28 @@
+package app.revanced.extension.youtube.patches.utils;
+
+import android.view.View;
+import android.widget.ImageView;
+
+import app.revanced.extension.shared.utils.Logger;
+
+@SuppressWarnings("unused")
+public class ToolBarPatch {
+
+ public static void hookToolBar(Enum> buttonEnum, ImageView imageView) {
+ final String enumString = buttonEnum.name();
+ if (enumString.isEmpty() ||
+ imageView == null ||
+ !(imageView.getParent() instanceof View view)) {
+ return;
+ }
+
+ Logger.printDebug(() -> "enumString: " + enumString);
+
+ hookToolBar(enumString, view);
+ }
+
+ private static void hookToolBar(String enumString, View parentView) {
+ }
+}
+
+
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/AV1CodecPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/AV1CodecPatch.java
new file mode 100644
index 0000000000..71f5dd9d65
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/AV1CodecPatch.java
@@ -0,0 +1,53 @@
+package app.revanced.extension.youtube.patches.video;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class AV1CodecPatch {
+ private static final int LITERAL_VALUE_AV01 = 1635135811;
+ private static final int LITERAL_VALUE_DOLBY_VISION = 1685485123;
+ private static final String VP9_CODEC = "video/x-vnd.on2.vp9";
+ private static long lastTimeResponse = 0;
+
+ /**
+ * Replace the SW AV01 codec to VP9 codec.
+ * May not be valid on some clients.
+ *
+ * @param original hardcoded value - "video/av01"
+ */
+ public static String replaceCodec(String original) {
+ return Settings.REPLACE_AV1_CODEC.get() ? VP9_CODEC : original;
+ }
+
+ /**
+ * Replace the SW AV01 codec request with a Dolby Vision codec request.
+ * This request is invalid, so it falls back to codecs other than AV01.
+ *
+ * Limitation: Fallback process causes about 15-20 seconds of buffering.
+ *
+ * @param literalValue literal value of the codec
+ */
+ public static int rejectResponse(int literalValue) {
+ if (!Settings.REJECT_AV1_CODEC.get())
+ return literalValue;
+
+ Logger.printDebug(() -> "Response: " + literalValue);
+
+ if (literalValue != LITERAL_VALUE_AV01)
+ return literalValue;
+
+ final long currentTime = System.currentTimeMillis();
+
+ // Ignore the invoke within 20 seconds.
+ if (currentTime - lastTimeResponse > 20000) {
+ lastTimeResponse = currentTime;
+ Utils.showToastShort(str("revanced_reject_av1_codec_toast"));
+ }
+
+ return LITERAL_VALUE_DOLBY_VISION;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/CustomPlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/CustomPlaybackSpeedPatch.java
new file mode 100644
index 0000000000..d546921536
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/CustomPlaybackSpeedPatch.java
@@ -0,0 +1,266 @@
+package app.revanced.extension.youtube.patches.video;
+
+import static app.revanced.extension.shared.utils.ResourceUtils.getString;
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.content.Context;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.components.PlaybackSpeedMenuFilter;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.utils.VideoUtils;
+
+@SuppressWarnings("unused")
+public class CustomPlaybackSpeedPatch {
+ /**
+ * Maximum playback speed, exclusive value. Custom speeds must be less than this value.
+ *
+ * Going over 8x does not increase the actual playback speed any higher,
+ * and the UI selector starts flickering and acting weird.
+ * Over 10x and the speeds show up out of order in the UI selector.
+ */
+ public static final float MAXIMUM_PLAYBACK_SPEED = 8;
+ private static final String[] defaultSpeedEntries;
+ private static final String[] defaultSpeedEntryValues;
+ /**
+ * Custom playback speeds.
+ */
+ private static float[] playbackSpeeds;
+ private static String[] customSpeedEntries;
+ private static String[] customSpeedEntryValues;
+
+ private static String[] playbackSpeedEntries;
+ private static String[] playbackSpeedEntryValues;
+
+ /**
+ * The last time the old playback menu was forcefully called.
+ */
+ private static long lastTimeOldPlaybackMenuInvoked;
+
+ static {
+ defaultSpeedEntries = new String[]{getString("quality_auto"), "0.25x", "0.5x", "0.75x", getString("revanced_playback_speed_normal"), "1.25x", "1.5x", "1.75x", "2.0x"};
+ defaultSpeedEntryValues = new String[]{"-2.0", "0.25", "0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0"};
+
+ loadCustomSpeeds();
+ }
+
+ /**
+ * Injection point.
+ */
+ public static float[] getArray(float[] original) {
+ return isCustomPlaybackSpeedEnabled() ? playbackSpeeds : original;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static int getLength(int original) {
+ return isCustomPlaybackSpeedEnabled() ? playbackSpeeds.length : original;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static int getSize(int original) {
+ return isCustomPlaybackSpeedEnabled() ? 0 : original;
+ }
+
+ public static String[] getListEntries() {
+ return isCustomPlaybackSpeedEnabled()
+ ? customSpeedEntries
+ : defaultSpeedEntries;
+ }
+
+ public static String[] getListEntryValues() {
+ return isCustomPlaybackSpeedEnabled()
+ ? customSpeedEntryValues
+ : defaultSpeedEntryValues;
+ }
+
+ public static String[] getTrimmedListEntries() {
+ if (playbackSpeedEntries == null) {
+ final String[] playbackSpeedWithAutoEntries = getListEntries();
+ playbackSpeedEntries = Arrays.copyOfRange(playbackSpeedWithAutoEntries, 1, playbackSpeedWithAutoEntries.length);
+ }
+
+ return playbackSpeedEntries;
+ }
+
+ public static String[] getTrimmedListEntryValues() {
+ if (playbackSpeedEntryValues == null) {
+ final String[] playbackSpeedWithAutoEntryValues = getListEntryValues();
+ playbackSpeedEntryValues = Arrays.copyOfRange(playbackSpeedWithAutoEntryValues, 1, playbackSpeedWithAutoEntryValues.length);
+ }
+
+ return playbackSpeedEntryValues;
+ }
+
+ private static void resetCustomSpeeds(@NonNull String toastMessage) {
+ Utils.showToastLong(toastMessage);
+ Utils.showToastShort(str("revanced_extended_reset_to_default_toast"));
+ Settings.CUSTOM_PLAYBACK_SPEEDS.resetToDefault();
+ }
+
+ private static void loadCustomSpeeds() {
+ try {
+ if (!Settings.ENABLE_CUSTOM_PLAYBACK_SPEED.get()) {
+ return;
+ }
+
+ String[] speedStrings = Settings.CUSTOM_PLAYBACK_SPEEDS.get().split("\\s+");
+ Arrays.sort(speedStrings);
+ if (speedStrings.length == 0) {
+ throw new IllegalArgumentException();
+ }
+ playbackSpeeds = new float[speedStrings.length];
+ int i = 0;
+ for (String speedString : speedStrings) {
+ final float speedFloat = Float.parseFloat(speedString);
+ if (speedFloat <= 0 || arrayContains(playbackSpeeds, speedFloat)) {
+ throw new IllegalArgumentException();
+ }
+
+ if (speedFloat > MAXIMUM_PLAYBACK_SPEED) {
+ resetCustomSpeeds(str("revanced_custom_playback_speeds_invalid", MAXIMUM_PLAYBACK_SPEED));
+ loadCustomSpeeds();
+ return;
+ }
+
+ playbackSpeeds[i] = speedFloat;
+ i++;
+ }
+
+ if (customSpeedEntries != null) return;
+
+ customSpeedEntries = new String[playbackSpeeds.length + 1];
+ customSpeedEntryValues = new String[playbackSpeeds.length + 1];
+ customSpeedEntries[0] = getString("quality_auto");
+ customSpeedEntryValues[0] = "-2.0";
+
+ i = 1;
+ for (float speed : playbackSpeeds) {
+ String speedString = String.valueOf(speed);
+ customSpeedEntries[i] = speed != 1.0f
+ ? speedString + "x"
+ : getString("revanced_playback_speed_normal");
+ customSpeedEntryValues[i] = speedString;
+ i++;
+ }
+ } catch (Exception ex) {
+ Logger.printInfo(() -> "parse error", ex);
+ resetCustomSpeeds(str("revanced_custom_playback_speeds_parse_exception"));
+ loadCustomSpeeds();
+ }
+ }
+
+ private static boolean arrayContains(float[] array, float value) {
+ for (float arrayValue : array) {
+ if (arrayValue == value) return true;
+ }
+ return false;
+ }
+
+ private static boolean isCustomPlaybackSpeedEnabled() {
+ return Settings.ENABLE_CUSTOM_PLAYBACK_SPEED.get() && playbackSpeeds != null;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void onFlyoutMenuCreate(RecyclerView recyclerView) {
+ if (!Settings.ENABLE_CUSTOM_PLAYBACK_SPEED.get()) {
+ return;
+ }
+
+ recyclerView.getViewTreeObserver().addOnDrawListener(() -> {
+ try {
+ if (PlaybackSpeedMenuFilter.isOldPlaybackSpeedMenuVisible) {
+ if (hideLithoMenuAndShowOldSpeedMenu(recyclerView, 8)) {
+ PlaybackSpeedMenuFilter.isOldPlaybackSpeedMenuVisible = false;
+ }
+ return;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "isOldPlaybackSpeedMenuVisible failure", ex);
+ }
+
+ try {
+ if (PlaybackSpeedMenuFilter.isPlaybackRateSelectorMenuVisible) {
+ if (hideLithoMenuAndShowOldSpeedMenu(recyclerView, 5)) {
+ PlaybackSpeedMenuFilter.isPlaybackRateSelectorMenuVisible = false;
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "isPlaybackRateSelectorMenuVisible failure", ex);
+ }
+ });
+ }
+
+ private static boolean hideLithoMenuAndShowOldSpeedMenu(RecyclerView recyclerView, int expectedChildCount) {
+ if (recyclerView.getChildCount() == 0) {
+ return false;
+ }
+
+ if (!(recyclerView.getChildAt(0) instanceof ViewGroup PlaybackSpeedParentView)) {
+ return false;
+ }
+
+ if (PlaybackSpeedParentView.getChildCount() != expectedChildCount) {
+ return false;
+ }
+
+ if (!(Utils.getParentView(recyclerView, 3) instanceof ViewGroup parentView3rd)) {
+ return false;
+ }
+
+ if (!(parentView3rd.getParent() instanceof ViewGroup parentView4th)) {
+ return false;
+ }
+
+ // 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);
+
+ // Show old playback speed menu.
+ showCustomPlaybackSpeedMenu(recyclerView.getContext());
+
+ return true;
+ }
+
+ /**
+ * This method is sometimes used multiple times
+ * To prevent this, ignore method reuse within 1 second.
+ *
+ * @param context Context for [playbackSpeedDialogListener]
+ */
+ private static void showCustomPlaybackSpeedMenu(@NonNull Context context) {
+ // This method is sometimes used multiple times.
+ // To prevent this, ignore method reuse within 1 second.
+ final long now = System.currentTimeMillis();
+ if (now - lastTimeOldPlaybackMenuInvoked < 1000) {
+ return;
+ }
+ lastTimeOldPlaybackMenuInvoked = now;
+
+ if (Settings.CUSTOM_PLAYBACK_SPEED_MENU_TYPE.get()) {
+ // Open playback speed dialog
+ VideoUtils.showPlaybackSpeedDialog(context);
+ } else {
+ // Open old style flyout menu
+ VideoUtils.showPlaybackSpeedFlyoutMenu();
+ }
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/HDRVideoPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/HDRVideoPatch.java
new file mode 100644
index 0000000000..0ad3758c38
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/HDRVideoPatch.java
@@ -0,0 +1,11 @@
+package app.revanced.extension.youtube.patches.video;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class HDRVideoPatch {
+
+ public static boolean disableHDRVideo() {
+ return !Settings.DISABLE_HDR_VIDEO.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/PlaybackSpeedPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/PlaybackSpeedPatch.java
new file mode 100644
index 0000000000..e4a2417fa0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/PlaybackSpeedPatch.java
@@ -0,0 +1,139 @@
+package app.revanced.extension.youtube.patches.video;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.apache.commons.lang3.BooleanUtils;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.misc.requests.PlaylistRequest;
+import app.revanced.extension.youtube.patches.utils.PatchStatus;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.VideoInformation;
+import app.revanced.extension.youtube.whitelist.Whitelist;
+
+@SuppressWarnings("unused")
+public class PlaybackSpeedPatch {
+ private static final long TOAST_DELAY_MILLISECONDS = 750;
+ private static long lastTimeSpeedChanged;
+ private static boolean isLiveStream;
+
+ /**
+ * Injection point.
+ */
+ public static void newVideoStarted(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName,
+ @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle,
+ final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) {
+ isLiveStream = newlyLoadedLiveStreamValue;
+ Logger.printDebug(() -> "newVideoStarted: " + newlyLoadedVideoId);
+
+ final float defaultPlaybackSpeed = getDefaultPlaybackSpeed(newlyLoadedChannelId, newlyLoadedVideoId);
+ Logger.printDebug(() -> "overridePlaybackSpeed: " + defaultPlaybackSpeed);
+
+ VideoInformation.overridePlaybackSpeed(defaultPlaybackSpeed);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void fetchPlaylistData(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) {
+ if (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get()) {
+ try {
+ final boolean videoIdIsShort = VideoInformation.lastPlayerResponseIsShort();
+ // Shorts shelf in home and subscription feed causes player response hook to be called,
+ // and the 'is opening/playing' parameter will be false.
+ // This hook will be called again when the Short is actually opened.
+ if (videoIdIsShort && !isShortAndOpeningOrPlaying) {
+ return;
+ }
+
+ PlaylistRequest.fetchRequestIfNeeded(videoId);
+ } catch (Exception ex) {
+ Logger.printException(() -> "fetchPlaylistData failure", ex);
+ }
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static float getPlaybackSpeedInShorts(final float playbackSpeed) {
+ if (!VideoInformation.lastPlayerResponseIsShort())
+ return playbackSpeed;
+ if (!Settings.ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS.get())
+ return playbackSpeed;
+
+ float defaultPlaybackSpeed = getDefaultPlaybackSpeed(VideoInformation.getChannelId(), null);
+ Logger.printDebug(() -> "overridePlaybackSpeed in Shorts: " + defaultPlaybackSpeed);
+
+ return defaultPlaybackSpeed;
+ }
+
+ /**
+ * Injection point.
+ * Called when user selects a playback speed.
+ *
+ * @param playbackSpeed The playback speed the user selected
+ */
+ public static void userSelectedPlaybackSpeed(float playbackSpeed) {
+ if (PatchStatus.RememberPlaybackSpeed() &&
+ Settings.REMEMBER_PLAYBACK_SPEED_LAST_SELECTED.get()) {
+ // With the 0.05x menu, if the speed is set by integrations to higher than 2.0x
+ // then the menu will allow increasing without bounds but the max speed is
+ // still capped to under 8.0x.
+ playbackSpeed = Math.min(playbackSpeed, CustomPlaybackSpeedPatch.MAXIMUM_PLAYBACK_SPEED - 0.05f);
+
+ // Prevent toast spamming if using the 0.05x adjustments.
+ // Show exactly one toast after the user stops interacting with the speed menu.
+ final long now = System.currentTimeMillis();
+ lastTimeSpeedChanged = now;
+
+ final float finalPlaybackSpeed = playbackSpeed;
+ Utils.runOnMainThreadDelayed(() -> {
+ if (lastTimeSpeedChanged != now) {
+ // The user made additional speed adjustments and this call is outdated.
+ return;
+ }
+
+ if (Settings.DEFAULT_PLAYBACK_SPEED.get() == finalPlaybackSpeed) {
+ // User changed to a different speed and immediately changed back.
+ // Or the user is going past 8.0x in the glitched out 0.05x menu.
+ return;
+ }
+ Settings.DEFAULT_PLAYBACK_SPEED.save(finalPlaybackSpeed);
+
+ if (!Settings.REMEMBER_PLAYBACK_SPEED_LAST_SELECTED_TOAST.get()) {
+ return;
+ }
+ Utils.showToastShort(str("revanced_remember_playback_speed_toast", (finalPlaybackSpeed + "x")));
+ }, TOAST_DELAY_MILLISECONDS);
+ }
+ }
+
+ private static float getDefaultPlaybackSpeed(@NonNull String channelId, @Nullable String videoId) {
+ return (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_LIVE.get() && isLiveStream) ||
+ Whitelist.isChannelWhitelistedPlaybackSpeed(channelId) ||
+ getPlaylistData(videoId)
+ ? 1.0f
+ : Settings.DEFAULT_PLAYBACK_SPEED.get();
+ }
+
+ private static boolean getPlaylistData(@Nullable String videoId) {
+ if (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get() && videoId != null) {
+ try {
+ PlaylistRequest request = PlaylistRequest.getRequestForVideoId(videoId);
+ final boolean isPlaylist = request != null && BooleanUtils.toBoolean(request.getStream());
+ Logger.printDebug(() -> "isPlaylist: " + isPlaylist);
+
+ return isPlaylist;
+ } catch (Exception ex) {
+ Logger.printException(() -> "getPlaylistData failure", ex);
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/ReloadVideoPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/ReloadVideoPatch.java
new file mode 100644
index 0000000000..b2516e7ce6
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/ReloadVideoPatch.java
@@ -0,0 +1,53 @@
+package app.revanced.extension.youtube.patches.video;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+import app.revanced.extension.youtube.shared.VideoInformation;
+
+@SuppressWarnings("unused")
+public class ReloadVideoPatch {
+ private static final long RELOAD_VIDEO_TIME_MILLISECONDS = 15000L;
+
+ @NonNull
+ public static String videoId = "";
+
+ /**
+ * Injection point.
+ */
+ public static void newVideoStarted(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName,
+ @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle,
+ final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) {
+ if (!Settings.SKIP_PRELOADED_BUFFER.get())
+ return;
+ if (PlayerType.getCurrent() == PlayerType.INLINE_MINIMAL)
+ return;
+ if (videoId.equals(newlyLoadedVideoId))
+ return;
+ videoId = newlyLoadedVideoId;
+
+ if (newlyLoadedVideoLength < RELOAD_VIDEO_TIME_MILLISECONDS || newlyLoadedLiveStreamValue)
+ return;
+
+ final long seekTime = Math.max(RELOAD_VIDEO_TIME_MILLISECONDS, (long) (newlyLoadedVideoLength * 0.5));
+
+ Utils.runOnMainThreadDelayed(() -> reloadVideo(seekTime), 250);
+ }
+
+ private static void reloadVideo(final long videoLength) {
+ final long lastVideoTime = VideoInformation.getVideoTime();
+ final float playbackSpeed = VideoInformation.getPlaybackSpeed();
+ final long speedAdjustedTimeThreshold = (long) (playbackSpeed * 300);
+ VideoInformation.overrideVideoTime(videoLength);
+ VideoInformation.overrideVideoTime(lastVideoTime + speedAdjustedTimeThreshold);
+
+ if (!Settings.SKIP_PRELOADED_BUFFER_TOAST.get())
+ return;
+
+ Utils.showToastShort(str("revanced_skipped_preloaded_buffer"));
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/RestoreOldVideoQualityMenuPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/RestoreOldVideoQualityMenuPatch.java
new file mode 100644
index 0000000000..b1ac0c9794
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/RestoreOldVideoQualityMenuPatch.java
@@ -0,0 +1,73 @@
+package app.revanced.extension.youtube.patches.video;
+
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ListView;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.components.VideoQualityMenuFilter;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class RestoreOldVideoQualityMenuPatch {
+
+ public static boolean restoreOldVideoQualityMenu() {
+ return Settings.RESTORE_OLD_VIDEO_QUALITY_MENU.get();
+ }
+
+ public static void restoreOldVideoQualityMenu(ListView listView) {
+ if (!Settings.RESTORE_OLD_VIDEO_QUALITY_MENU.get())
+ return;
+
+ listView.setVisibility(View.GONE);
+
+ Utils.runOnMainThreadDelayed(() -> {
+ listView.setSoundEffectsEnabled(false);
+ listView.performItemClick(null, 2, 0);
+ },
+ 1
+ );
+ }
+
+ public static void onFlyoutMenuCreate(final RecyclerView recyclerView) {
+ if (!Settings.RESTORE_OLD_VIDEO_QUALITY_MENU.get())
+ return;
+
+ recyclerView.getViewTreeObserver().addOnDrawListener(() -> {
+ try {
+ // Check if the current view is the quality menu.
+ if (!VideoQualityMenuFilter.isVideoQualityMenuVisible || recyclerView.getChildCount() == 0) {
+ return;
+ }
+
+ if (!(Utils.getParentView(recyclerView, 3) instanceof ViewGroup quickQualityViewParent)) {
+ return;
+ }
+
+ if (!(recyclerView.getChildAt(0) instanceof ViewGroup advancedQualityParentView)) {
+ return;
+ }
+
+ if (advancedQualityParentView.getChildCount() < 4) {
+ return;
+ }
+
+ View advancedQualityView = advancedQualityParentView.getChildAt(3);
+ if (advancedQualityView == null) {
+ return;
+ }
+
+ quickQualityViewParent.setVisibility(View.GONE);
+
+ // Click the "Advanced" quality menu to show the "old" quality menu.
+ advancedQualityView.callOnClick();
+
+ VideoQualityMenuFilter.isVideoQualityMenuVisible = false;
+ } catch (Exception ex) {
+ Logger.printException(() -> "onFlyoutMenuCreate failure", ex);
+ }
+ });
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/SpoofDeviceDimensionsPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/SpoofDeviceDimensionsPatch.java
new file mode 100644
index 0000000000..d5e4e28013
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/SpoofDeviceDimensionsPatch.java
@@ -0,0 +1,16 @@
+package app.revanced.extension.youtube.patches.video;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class SpoofDeviceDimensionsPatch {
+ private static final boolean SPOOF = Settings.SPOOF_DEVICE_DIMENSIONS.get();
+
+ public static int getMinHeightOrWidth(int minHeightOrWidth) {
+ return SPOOF ? 64 : minHeightOrWidth;
+ }
+
+ public static int getMaxHeightOrWidth(int maxHeightOrWidth) {
+ return SPOOF ? 4096 : maxHeightOrWidth;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/VP9CodecPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/VP9CodecPatch.java
new file mode 100644
index 0000000000..6052c55ef0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/VP9CodecPatch.java
@@ -0,0 +1,11 @@
+package app.revanced.extension.youtube.patches.video;
+
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public class VP9CodecPatch {
+
+ public static boolean disableVP9Codec() {
+ return !Settings.DISABLE_VP9_CODEC.get();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/VideoQualityPatch.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/VideoQualityPatch.java
new file mode 100644
index 0000000000..22b51c3342
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/patches/video/VideoQualityPatch.java
@@ -0,0 +1,91 @@
+package app.revanced.extension.youtube.patches.video;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import androidx.annotation.NonNull;
+
+import app.revanced.extension.shared.settings.IntegerSetting;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+import app.revanced.extension.youtube.shared.VideoInformation;
+
+@SuppressWarnings("unused")
+public class VideoQualityPatch {
+ private static final int DEFAULT_YOUTUBE_VIDEO_QUALITY = -2;
+ private static final IntegerSetting mobileQualitySetting = Settings.DEFAULT_VIDEO_QUALITY_MOBILE;
+ private static final IntegerSetting wifiQualitySetting = Settings.DEFAULT_VIDEO_QUALITY_WIFI;
+
+ @NonNull
+ public static String videoId = "";
+
+ /**
+ * Injection point.
+ */
+ public static void newVideoStarted() {
+ setVideoQuality(0);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void newVideoStarted(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName,
+ @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle,
+ final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) {
+ if (PlayerType.getCurrent() == PlayerType.INLINE_MINIMAL)
+ return;
+ if (videoId.equals(newlyLoadedVideoId))
+ return;
+ videoId = newlyLoadedVideoId;
+ setVideoQuality(Settings.SKIP_PRELOADED_BUFFER.get() ? 250 : 500);
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void userSelectedVideoQuality() {
+ Utils.runOnMainThreadDelayed(() ->
+ userSelectedVideoQuality(VideoInformation.getVideoQuality()),
+ 300
+ );
+ }
+
+ private static void setVideoQuality(final long delayMillis) {
+ final int defaultQuality = Utils.getNetworkType() == Utils.NetworkType.MOBILE
+ ? mobileQualitySetting.get()
+ : wifiQualitySetting.get();
+
+ if (defaultQuality == DEFAULT_YOUTUBE_VIDEO_QUALITY)
+ return;
+
+ Utils.runOnMainThreadDelayed(() ->
+ VideoInformation.overrideVideoQuality(
+ VideoInformation.getAvailableVideoQuality(defaultQuality)
+ ),
+ delayMillis
+ );
+ }
+
+ private static void userSelectedVideoQuality(final int defaultQuality) {
+ if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED.get())
+ return;
+ if (defaultQuality == DEFAULT_YOUTUBE_VIDEO_QUALITY)
+ return;
+
+ final Utils.NetworkType networkType = Utils.getNetworkType();
+
+ switch (networkType) {
+ case NONE -> {
+ Utils.showToastShort(str("revanced_remember_video_quality_none"));
+ return;
+ }
+ case MOBILE -> mobileQualitySetting.save(defaultQuality);
+ default -> wifiQualitySetting.save(defaultQuality);
+ }
+
+ if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED_TOAST.get())
+ return;
+
+ Utils.showToastShort(str("revanced_remember_video_quality_" + networkType.getName(), defaultQuality + "p"));
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java
new file mode 100644
index 0000000000..dd478f4f02
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/returnyoutubedislike/ReturnYouTubeDislike.java
@@ -0,0 +1,776 @@
+package app.revanced.extension.youtube.returnyoutubedislike;
+
+import static app.revanced.extension.shared.returnyoutubedislike.ReturnYouTubeDislike.Vote;
+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.content.res.Resources;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.ShapeDrawable;
+import android.graphics.drawable.shapes.OvalShape;
+import android.graphics.drawable.shapes.RectShape;
+import android.icu.text.CompactDecimalFormat;
+import android.icu.text.DecimalFormat;
+import android.icu.text.DecimalFormatSymbols;
+import android.icu.text.NumberFormat;
+import android.text.Spannable;
+import android.text.SpannableString;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.ImageSpan;
+import android.text.style.ReplacementSpan;
+import android.util.DisplayMetrics;
+import android.util.TypedValue;
+
+import androidx.annotation.GuardedBy;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+import app.revanced.extension.shared.returnyoutubedislike.requests.RYDVoteData;
+import app.revanced.extension.shared.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+import app.revanced.extension.youtube.utils.ThemeUtils;
+
+/**
+ * Handles fetching and creation/replacing of RYD dislike text spans.
+ *
+ * Because Litho creates spans using multiple threads, this entire class supports multithreading as well.
+ */
+public class ReturnYouTubeDislike {
+
+ /**
+ * Maximum amount of time to block the UI from updates while waiting for network call to complete.
+ *
+ * Must be less than 5 seconds, as per:
+ * ...
+ */
+ private static final long MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH = 4000;
+
+ /**
+ * How long to retain successful RYD fetches.
+ */
+ private static final long CACHE_TIMEOUT_SUCCESS_MILLISECONDS = 7 * 60 * 1000; // 7 Minutes
+
+ /**
+ * How long to retain unsuccessful RYD fetches,
+ * and also the minimum time before retrying again.
+ */
+ private static final long CACHE_TIMEOUT_FAILURE_MILLISECONDS = 3 * 60 * 1000; // 3 Minutes
+
+ /**
+ * Unique placeholder character, used to detect if a segmented span already has dislikes added to it.
+ * Must be something YouTube is unlikely to use, as it's searched for in all usage of Rolling Number.
+ */
+ private static final char MIDDLE_SEPARATOR_CHARACTER = '◎'; // 'bullseye'
+
+ public static final boolean IS_SPOOFING_TO_OLD_SEPARATOR_COLOR =
+ isSpoofingToLessThan("18.10.00");
+
+ /**
+ * Cached lookup of all video ids.
+ */
+ @GuardedBy("itself")
+ private static final Map fetchCache = new HashMap<>();
+
+ /**
+ * Used to send votes, one by one, in the same order the user created them.
+ */
+ private static final ExecutorService voteSerialExecutor = Executors.newSingleThreadExecutor();
+
+ /**
+ * For formatting dislikes as number.
+ */
+ @GuardedBy("ReturnYouTubeDislike.class") // not thread safe
+ private static CompactDecimalFormat dislikeCountFormatter;
+
+ /**
+ * For formatting dislikes as percentage.
+ */
+ @GuardedBy("ReturnYouTubeDislike.class")
+ private static NumberFormat dislikePercentageFormatter;
+
+ // Used for segmented dislike spans in Litho regular player.
+ public static final Rect leftSeparatorBounds;
+ private static final Rect middleSeparatorBounds;
+
+ /**
+ * Left separator horizontal padding for Rolling Number layout.
+ */
+ public static final int leftSeparatorShapePaddingPixels;
+ private static final ShapeDrawable leftSeparatorShape;
+ public static final Locale locale;
+
+ static {
+ final Resources resources = Utils.getResources();
+ DisplayMetrics dp = resources.getDisplayMetrics();
+
+ leftSeparatorBounds = new Rect(0, 0,
+ (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 1.2f, dp),
+ (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 14, dp));
+ final int middleSeparatorSize =
+ (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 3.7f, dp);
+ middleSeparatorBounds = new Rect(0, 0, middleSeparatorSize, middleSeparatorSize);
+
+ leftSeparatorShapePaddingPixels = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10.0f, dp);
+
+ leftSeparatorShape = new ShapeDrawable(new RectShape());
+ leftSeparatorShape.setBounds(leftSeparatorBounds);
+ locale = resources.getConfiguration().getLocales().get(0);
+
+ ReturnYouTubeDislikeApi.toastOnConnectionError = Settings.RYD_TOAST_ON_CONNECTION_ERROR.get();
+ }
+
+ private final String videoId;
+
+ /**
+ * Stores the results of the vote api fetch, and used as a barrier to wait until fetch completes.
+ * Absolutely cannot be holding any lock during calls to {@link Future#get()}.
+ */
+ private final Future future;
+
+ /**
+ * Time this instance and the fetch future was created.
+ */
+ private final long timeFetched;
+
+ /**
+ * If this instance was previously used for a Short.
+ */
+ @GuardedBy("this")
+ private boolean isShort;
+
+ /**
+ * Optional current vote status of the UI. Used to apply a user vote that was done on a previous video viewing.
+ */
+ @Nullable
+ @GuardedBy("this")
+ private Vote userVote;
+
+ /**
+ * Original dislike span, before modifications.
+ */
+ @Nullable
+ @GuardedBy("this")
+ private Spanned originalDislikeSpan;
+
+ /**
+ * Replacement like/dislike span that includes formatted dislikes.
+ * Used to prevent recreating the same span multiple times.
+ */
+ @Nullable
+ @GuardedBy("this")
+ private SpannableString replacementLikeDislikeSpan;
+
+ /**
+ * Color of the left and middle separator, based on the color of the right separator.
+ * It's unknown where YT gets the color from, and the values here are approximated by hand.
+ * Ideally, this would be the actual color YT uses at runtime.
+ *
+ * Older versions before the 'Me' library tab use a slightly different color.
+ * If spoofing was previously used and is now turned off,
+ * or an old version was recently upgraded then the old colors are sometimes still used.
+ */
+ private static int getSeparatorColor() {
+ if (IS_SPOOFING_TO_OLD_SEPARATOR_COLOR) {
+ return ThemeUtils.isDarkTheme()
+ ? 0x29AAAAAA // transparent dark gray
+ : 0xFFD9D9D9; // light gray
+ }
+
+ return ThemeUtils.isDarkTheme()
+ ? 0x33FFFFFF
+ : 0xFFD9D9D9;
+ }
+
+ public static ShapeDrawable getLeftSeparatorDrawable() {
+ leftSeparatorShape.getPaint().setColor(getSeparatorColor());
+ return leftSeparatorShape;
+ }
+
+ /**
+ * @param isSegmentedButton If UI is using the segmented single UI component for both like and dislike.
+ */
+ @NonNull
+ private static SpannableString createDislikeSpan(@NonNull Spanned oldSpannable,
+ boolean isSegmentedButton,
+ boolean isRollingNumber,
+ @NonNull RYDVoteData voteData) {
+ if (!isSegmentedButton) {
+ // Simple replacement of 'dislike' with a number/percentage.
+ return newSpannableWithDislikes(oldSpannable, voteData);
+ }
+
+ // Note: Some locales use right to left layout (Arabic, Hebrew, etc).
+ // If making changes to this code, change device settings to a RTL language and verify layout is correct.
+ CharSequence oldLikes = oldSpannable;
+
+ // YouTube creators can hide the like count on a video,
+ // and the like count appears as a device language specific string that says 'Like'.
+ // Check if the string contains any numbers.
+ if (!Utils.containsNumber(oldLikes)) {
+ if (Settings.RYD_ESTIMATED_LIKE.get()) {
+ // Likes are hidden by video creator
+ //
+ // RYD does not directly provide like data, but can use an estimated likes
+ // using the same scale factor RYD applied to the raw dislikes.
+ //
+ // example video: https://www.youtube.com/watch?v=UnrU5vxCHxw
+ // RYD data: https://returnyoutubedislikeapi.com/votes?videoId=UnrU5vxCHxw
+ Logger.printDebug(() -> "Using estimated likes");
+ oldLikes = formatDislikeCount(voteData.getLikeCount());
+ } else {
+ // Change the "Likes" string to show that likes and dislikes are hidden.
+ String hiddenMessageString = str("revanced_ryd_video_likes_hidden_by_video_owner");
+ return newSpanUsingStylingOfAnotherSpan(oldSpannable, hiddenMessageString);
+ }
+ }
+
+ SpannableStringBuilder builder = new SpannableStringBuilder();
+ final boolean compactLayout = Settings.RYD_COMPACT_LAYOUT.get();
+
+ if (!compactLayout) {
+ String leftSeparatorString = getTextDirectionString();
+ final Spannable leftSeparatorSpan;
+ if (isRollingNumber) {
+ leftSeparatorSpan = new SpannableString(leftSeparatorString);
+ } else {
+ leftSeparatorString += " ";
+ leftSeparatorSpan = new SpannableString(leftSeparatorString);
+ // Styling spans cannot overwrite RTL or LTR character.
+ leftSeparatorSpan.setSpan(
+ new VerticallyCenteredImageSpan(getLeftSeparatorDrawable(), false),
+ 1, 2, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
+ leftSeparatorSpan.setSpan(
+ new FixedWidthEmptySpan(leftSeparatorShapePaddingPixels),
+ 2, 3, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
+ }
+ builder.append(leftSeparatorSpan);
+ }
+
+ // likes
+ builder.append(newSpanUsingStylingOfAnotherSpan(oldSpannable, oldLikes));
+
+ // middle separator
+ String middleSeparatorString = compactLayout
+ ? " " + MIDDLE_SEPARATOR_CHARACTER + " "
+ : " \u2009" + MIDDLE_SEPARATOR_CHARACTER + "\u2009 "; // u2009 = 'narrow space' character
+ final int shapeInsertionIndex = middleSeparatorString.length() / 2;
+ Spannable middleSeparatorSpan = new SpannableString(middleSeparatorString);
+ ShapeDrawable shapeDrawable = new ShapeDrawable(new OvalShape());
+ shapeDrawable.getPaint().setColor(getSeparatorColor());
+ shapeDrawable.setBounds(middleSeparatorBounds);
+ // Use original text width if using Rolling Number,
+ // to ensure the replacement styled span has the same width as the measured String,
+ // otherwise layout can be broken (especially on devices with small system font sizes).
+ middleSeparatorSpan.setSpan(
+ new VerticallyCenteredImageSpan(shapeDrawable, isRollingNumber),
+ shapeInsertionIndex, shapeInsertionIndex + 1, Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
+ builder.append(middleSeparatorSpan);
+
+ // dislikes
+ builder.append(newSpannableWithDislikes(oldSpannable, voteData));
+
+ return new SpannableString(builder);
+ }
+
+ private static @NonNull String getTextDirectionString() {
+ return Utils.isRightToLeftTextLayout()
+ ? "\u200F" // u200F = right to left character
+ : "\u200E"; // u200E = left to right character
+ }
+
+ /**
+ * @return If the text is likely for a previously created likes/dislikes segmented span.
+ */
+ public static boolean isPreviouslyCreatedSegmentedSpan(@NonNull String text) {
+ return text.indexOf(MIDDLE_SEPARATOR_CHARACTER) >= 0;
+ }
+
+ private static boolean spansHaveEqualTextAndColor(@NonNull Spanned one, @NonNull Spanned two) {
+ // Cannot use equals on the span, because many of the inner styling spans do not implement equals.
+ // Instead, compare the underlying text and the text color to handle when dark mode is changed.
+ // Cannot compare the status of device dark mode, as Litho components are updated just before dark mode status changes.
+ if (!one.toString().equals(two.toString())) {
+ return false;
+ }
+ ForegroundColorSpan[] oneColors = one.getSpans(0, one.length(), ForegroundColorSpan.class);
+ ForegroundColorSpan[] twoColors = two.getSpans(0, two.length(), ForegroundColorSpan.class);
+ final int oneLength = oneColors.length;
+ if (oneLength != twoColors.length) {
+ return false;
+ }
+ for (int i = 0; i < oneLength; i++) {
+ if (oneColors[i].getForegroundColor() != twoColors[i].getForegroundColor()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static SpannableString newSpannableWithLikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) {
+ return newSpanUsingStylingOfAnotherSpan(sourceStyling, formatDislikeCount(voteData.getLikeCount()));
+ }
+
+ private static SpannableString newSpannableWithDislikes(@NonNull Spanned sourceStyling, @NonNull RYDVoteData voteData) {
+ return newSpanUsingStylingOfAnotherSpan(sourceStyling,
+ Settings.RYD_DISLIKE_PERCENTAGE.get()
+ ? formatDislikePercentage(voteData.getDislikePercentage())
+ : formatDislikeCount(voteData.getDislikeCount()));
+ }
+
+ private static SpannableString newSpanUsingStylingOfAnotherSpan(@NonNull Spanned sourceStyle, @NonNull CharSequence newSpanText) {
+ if (sourceStyle == newSpanText && sourceStyle instanceof SpannableString spannableString) {
+ return spannableString; // Nothing to do.
+ }
+
+ SpannableString destination = new SpannableString(newSpanText);
+ Object[] spans = sourceStyle.getSpans(0, sourceStyle.length(), Object.class);
+ for (Object span : spans) {
+ destination.setSpan(span, 0, destination.length(), sourceStyle.getSpanFlags(span));
+ }
+
+ return destination;
+ }
+
+ private static String formatDislikeCount(long dislikeCount) {
+ if (isSDKAbove(24)) {
+ synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
+ if (dislikeCountFormatter == null) {
+ Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().getLocales().get(0);
+ dislikeCountFormatter = CompactDecimalFormat.getInstance(locale, CompactDecimalFormat.CompactStyle.SHORT);
+
+ // YouTube disregards locale specific number characters
+ // and instead shows english number characters everywhere.
+ // To use the same behavior, override the digit characters to use English
+ // so languages such as Arabic will show "1.234" instead of the native "۱,۲۳٤"
+ if (isSDKAbove(28)) {
+ DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale);
+ symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings());
+ dislikeCountFormatter.setDecimalFormatSymbols(symbols);
+ }
+ }
+ return dislikeCountFormatter.format(dislikeCount);
+ }
+ }
+
+ // Will never be reached, as the oldest supported YouTube app requires Android N or greater.
+ return String.valueOf(dislikeCount);
+ }
+
+ private static String formatDislikePercentage(float dislikePercentage) {
+ if (isSDKAbove(24)) {
+ synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
+ if (dislikePercentageFormatter == null) {
+ Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().getLocales().get(0);
+ dislikePercentageFormatter = NumberFormat.getPercentInstance(locale);
+
+ // Want to set the digit strings, and the simplest way is to cast to the implementation NumberFormat returns.
+ if (isSDKAbove(28) && dislikePercentageFormatter instanceof DecimalFormat decimalFormat) {
+ DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale);
+ symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings());
+ decimalFormat.setDecimalFormatSymbols(symbols);
+ }
+ }
+ if (dislikePercentage >= 0.01) { // at least 1%
+ dislikePercentageFormatter.setMaximumFractionDigits(0); // show only whole percentage points
+ } else {
+ dislikePercentageFormatter.setMaximumFractionDigits(1); // show up to 1 digit precision
+ }
+ return dislikePercentageFormatter.format(dislikePercentage);
+ }
+ }
+
+ // Will never be reached, as the oldest supported YouTube app requires Android N or greater.
+ return String.valueOf((int) (dislikePercentage * 100));
+ }
+
+ @NonNull
+ public static ReturnYouTubeDislike getFetchForVideoId(@Nullable String videoId) {
+ Objects.requireNonNull(videoId);
+ synchronized (fetchCache) {
+ // Remove any expired entries.
+ final long now = System.currentTimeMillis();
+ if (isSDKAbove(24)) {
+ fetchCache.values().removeIf(value -> {
+ final boolean expired = value.isExpired(now);
+ if (expired)
+ Logger.printDebug(() -> "Removing expired fetch: " + value.videoId);
+ return expired;
+ });
+ } else {
+ final Iterator> itr = fetchCache.entrySet().iterator();
+ while (itr.hasNext()) {
+ final Map.Entry entry = itr.next();
+ if (entry.getValue().isExpired(now)) {
+ Logger.printDebug(() -> "Removing expired fetch: " + entry.getValue().videoId);
+ itr.remove();
+ }
+ }
+ }
+
+ ReturnYouTubeDislike fetch = fetchCache.get(videoId);
+ if (fetch == null) {
+ fetch = new ReturnYouTubeDislike(videoId);
+ fetchCache.put(videoId, fetch);
+ }
+ return fetch;
+ }
+ }
+
+ /**
+ * Should be called if the user changes dislikes appearance settings.
+ */
+ public static void clearAllUICaches() {
+ synchronized (fetchCache) {
+ for (ReturnYouTubeDislike fetch : fetchCache.values()) {
+ fetch.clearUICache();
+ }
+ }
+ }
+
+ private ReturnYouTubeDislike(@NonNull String videoId) {
+ this.videoId = Objects.requireNonNull(videoId);
+ this.timeFetched = System.currentTimeMillis();
+ this.future = Utils.submitOnBackgroundThread(() -> ReturnYouTubeDislikeApi.fetchVotes(videoId));
+ }
+
+ private boolean isExpired(long now) {
+ final long timeSinceCreation = now - timeFetched;
+ if (timeSinceCreation < CACHE_TIMEOUT_FAILURE_MILLISECONDS) {
+ return false; // Not expired, even if the API call failed.
+ }
+ if (timeSinceCreation > CACHE_TIMEOUT_SUCCESS_MILLISECONDS) {
+ return true; // Always expired.
+ }
+ // Only expired if the fetch failed (API null response).
+ return (!fetchCompleted() || getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH) == null);
+ }
+
+ @Nullable
+ public RYDVoteData getFetchData(long maxTimeToWait) {
+ try {
+ return future.get(maxTimeToWait, TimeUnit.MILLISECONDS);
+ } catch (TimeoutException ex) {
+ Logger.printDebug(() -> "Waited but future was not complete after: " + maxTimeToWait + "ms");
+ } catch (ExecutionException | InterruptedException ex) {
+ Logger.printException(() -> "Future failure ", ex); // will never happen
+ }
+ return null;
+ }
+
+ /**
+ * @return if the RYD fetch call has completed.
+ */
+ public boolean fetchCompleted() {
+ return future.isDone();
+ }
+
+ private synchronized void clearUICache() {
+ if (replacementLikeDislikeSpan != null) {
+ Logger.printDebug(() -> "Clearing replacement span for: " + videoId);
+ }
+ replacementLikeDislikeSpan = null;
+ }
+
+ /**
+ * Must call off main thread, as this will make a network call if user is not yet registered.
+ *
+ * @return ReturnYouTubeDislike user ID. If user registration has never happened
+ * and the network call fails, this returns NULL.
+ */
+ @Nullable
+ private static String getUserId() {
+ Utils.verifyOffMainThread();
+
+ String userId = Settings.RYD_USER_ID.get();
+ if (!userId.isEmpty()) {
+ return userId;
+ }
+
+ userId = ReturnYouTubeDislikeApi.registerAsNewUser();
+ if (userId != null) {
+ Settings.RYD_USER_ID.save(userId);
+ }
+ return userId;
+ }
+
+ @NonNull
+ public String getVideoId() {
+ return videoId;
+ }
+
+ /**
+ * Pre-emptively set this as a Short.
+ */
+ public synchronized void setVideoIdIsShort(boolean isShort) {
+ this.isShort = isShort;
+ }
+
+ /**
+ * @return the replacement span containing dislikes, or the original span if RYD is not available.
+ */
+ @NonNull
+ public synchronized Spanned getDislikesSpanForRegularVideo(@NonNull Spanned original,
+ boolean isSegmentedButton,
+ boolean isRollingNumber) {
+ return waitForFetchAndUpdateReplacementSpan(original, isSegmentedButton,
+ isRollingNumber, false, false);
+ }
+
+ /**
+ * Called when a Shorts like Spannable is created.
+ */
+ @NonNull
+ public synchronized Spanned getLikeSpanForShort(@NonNull Spanned original) {
+ return waitForFetchAndUpdateReplacementSpan(original, false,
+ false, true, true);
+ }
+
+ /**
+ * Called when a Shorts dislike Spannable is created.
+ */
+ @NonNull
+ public synchronized Spanned getDislikeSpanForShort(@NonNull Spanned original) {
+ return waitForFetchAndUpdateReplacementSpan(original, false,
+ false, true, false);
+ }
+
+ @NonNull
+ private Spanned waitForFetchAndUpdateReplacementSpan(@NonNull Spanned original,
+ boolean isSegmentedButton,
+ boolean isRollingNumber,
+ boolean spanIsForShort,
+ boolean spanIsForLikes) {
+ try {
+ RYDVoteData votingData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH);
+ if (votingData == null) {
+ Logger.printDebug(() -> "Cannot add dislike to UI (RYD data not available)");
+ return original;
+ }
+
+ synchronized (this) {
+ if (spanIsForShort) {
+ // Cannot set this to false if span is not for a Short.
+ // When spoofing to an old version and a Short is opened while a regular video
+ // is on screen, this instance can be loaded for the minimized regular video.
+ // But this Shorts data won't be displayed for that call
+ // and when it is un-minimized it will reload again and the load will be ignored.
+ isShort = true;
+ } else if (isShort) {
+ // user:
+ // 1, opened a video
+ // 2. opened a short (without closing the regular video)
+ // 3. closed the short
+ // 4. regular video is now present, but the videoId and RYD data is still for the short
+ Logger.printDebug(() -> "Ignoring regular video dislike span,"
+ + " as data loaded was previously used for a Short: " + videoId);
+ return original;
+ }
+
+ // prevents reproducible bugs with the following steps:
+ // (user is using YouTube with RollingNumber applied)
+ // 1. opened a video
+ // 2. switched to fullscreen
+ // 3. click video's title to open the video description
+ // 4. dislike count may be replaced in the like count area or view count area of the video description
+ if (PlayerType.getCurrent().isFullScreenOrSlidingFullScreen()) {
+ Logger.printDebug(() -> "Ignoring fullscreen video description panel: " + videoId);
+ return original;
+ }
+
+ if (spanIsForLikes) {
+ // Scrolling Shorts does not cause the Spans to be reloaded,
+ // so there is no need to cache the likes for this situations.
+ Logger.printDebug(() -> "Creating likes span for: " + votingData.videoId);
+ return newSpannableWithLikes(original, votingData);
+ }
+
+ if (originalDislikeSpan != null && replacementLikeDislikeSpan != null
+ && spansHaveEqualTextAndColor(original, originalDislikeSpan)) {
+ Logger.printDebug(() -> "Replacing span with previously created dislike span of data: " + videoId);
+ return replacementLikeDislikeSpan;
+ }
+
+ // No replacement span exist, create it now.
+
+ if (userVote != null) {
+ votingData.updateUsingVote(userVote);
+ }
+ originalDislikeSpan = original;
+ replacementLikeDislikeSpan = createDislikeSpan(original, isSegmentedButton, isRollingNumber, votingData);
+ Logger.printDebug(() -> "Replaced: '" + originalDislikeSpan + "' with: '"
+ + replacementLikeDislikeSpan + "'" + " using video: " + videoId);
+
+ return replacementLikeDislikeSpan;
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "waitForFetchAndUpdateReplacementSpan failure", ex);
+ }
+
+ return original;
+ }
+
+ public void sendVote(@NonNull Vote vote) {
+ Utils.verifyOnMainThread();
+ Objects.requireNonNull(vote);
+ try {
+ if (isShort != PlayerType.getCurrent().isNoneOrHidden()) {
+ // Shorts was loaded with regular video present, then Shorts was closed.
+ // and then user voted on the now visible original video.
+ // Cannot send a vote, because this instance is for the wrong video.
+ Utils.showToastLong(str("revanced_ryd_failure_ryd_enabled_while_playing_video_then_user_voted"));
+ return;
+ }
+
+ setUserVote(vote);
+
+ voteSerialExecutor.execute(() -> {
+ try { // Must wrap in try/catch to properly log exceptions.
+ ReturnYouTubeDislikeApi.sendVote(getUserId(), videoId, vote);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to send vote", ex);
+ }
+ });
+ } catch (Exception ex) {
+ Logger.printException(() -> "Error trying to send vote", ex);
+ }
+ }
+
+ /**
+ * Sets the current user vote value, and does not send the vote to the RYD API.
+ *
+ * Only used to set value if thumbs up/down is already selected on video load.
+ */
+ public void setUserVote(@NonNull Vote vote) {
+ Objects.requireNonNull(vote);
+ try {
+ Logger.printDebug(() -> "setUserVote: " + vote);
+
+ synchronized (this) {
+ userVote = vote;
+ clearUICache();
+ }
+
+ if (future.isDone()) {
+ // Update the fetched vote data.
+ RYDVoteData voteData = getFetchData(MAX_MILLISECONDS_TO_BLOCK_UI_WAITING_FOR_FETCH);
+ if (voteData == null) {
+ // RYD fetch failed.
+ Logger.printDebug(() -> "Cannot update UI (vote data not available)");
+ return;
+ }
+ voteData.updateUsingVote(vote);
+ } // Else, vote will be applied after fetch completes.
+
+ } catch (Exception ex) {
+ Logger.printException(() -> "setUserVote failure", ex);
+ }
+ }
+}
+
+/**
+ * Styles a Spannable with an empty fixed width.
+ */
+class FixedWidthEmptySpan extends ReplacementSpan {
+ final int fixedWidth;
+
+ /**
+ * @param fixedWith Fixed width in screen pixels.
+ */
+ FixedWidthEmptySpan(int fixedWith) {
+ this.fixedWidth = fixedWith;
+ if (fixedWith < 0) throw new IllegalArgumentException();
+ }
+
+ @Override
+ public int getSize(@NonNull Paint paint, @NonNull CharSequence text,
+ int start, int end, @Nullable Paint.FontMetricsInt fontMetrics) {
+ return fixedWidth;
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end,
+ float x, int top, int y, int bottom, @NonNull Paint paint) {
+ // Nothing to draw.
+ }
+}
+
+/**
+ * Vertically centers a Spanned Drawable.
+ */
+class VerticallyCenteredImageSpan extends ImageSpan {
+ final boolean useOriginalWidth;
+
+ /**
+ * @param useOriginalWidth Use the original layout width of the text this span is applied to,
+ * and not the bounds of the Drawable. Drawable is always displayed using it's own bounds,
+ * and this setting only affects the layout width of the entire span.
+ */
+ public VerticallyCenteredImageSpan(Drawable drawable, boolean useOriginalWidth) {
+ super(drawable);
+ this.useOriginalWidth = useOriginalWidth;
+ }
+
+ @Override
+ public int getSize(@NonNull Paint paint, @NonNull CharSequence text,
+ int start, int end, @Nullable Paint.FontMetricsInt fontMetrics) {
+ Drawable drawable = getDrawable();
+ Rect bounds = drawable.getBounds();
+ if (fontMetrics != null) {
+ Paint.FontMetricsInt paintMetrics = paint.getFontMetricsInt();
+ final int fontHeight = paintMetrics.descent - paintMetrics.ascent;
+ final int drawHeight = bounds.bottom - bounds.top;
+ final int halfDrawHeight = drawHeight / 2;
+ final int yCenter = paintMetrics.ascent + fontHeight / 2;
+
+ fontMetrics.ascent = yCenter - halfDrawHeight;
+ fontMetrics.top = fontMetrics.ascent;
+ fontMetrics.bottom = yCenter + halfDrawHeight;
+ fontMetrics.descent = fontMetrics.bottom;
+ }
+ if (useOriginalWidth) {
+ return (int) paint.measureText(text, start, end);
+ }
+ return bounds.right;
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end,
+ float x, int top, int y, int bottom, @NonNull Paint paint) {
+ Drawable drawable = getDrawable();
+ canvas.save();
+ Paint.FontMetricsInt paintMetrics = paint.getFontMetricsInt();
+ final int fontHeight = paintMetrics.descent - paintMetrics.ascent;
+ final int yCenter = y + paintMetrics.descent - fontHeight / 2;
+ final Rect drawBounds = drawable.getBounds();
+ float translateX = x;
+ if (useOriginalWidth) {
+ // Horizontally center the drawable in the same space as the original text.
+ translateX += (paint.measureText(text, start, end) - (drawBounds.right - drawBounds.left)) / 2;
+ }
+ final int translateY = yCenter - (drawBounds.bottom - drawBounds.top) / 2;
+ canvas.translate(translateX, translateY);
+ drawable.draw(canvas);
+ canvas.restore();
+ }
+}
\ 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
new file mode 100644
index 0000000000..bdb78aaa51
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/Settings.java
@@ -0,0 +1,654 @@
+package app.revanced.extension.youtube.settings;
+
+import static java.lang.Boolean.FALSE;
+import static java.lang.Boolean.TRUE;
+import static app.revanced.extension.shared.settings.Setting.migrateFromOldPreferences;
+import static app.revanced.extension.shared.settings.Setting.parent;
+import static app.revanced.extension.shared.settings.Setting.parentsAny;
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType;
+import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_1;
+import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_2;
+import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_3;
+import static app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour.MANUAL_SKIP;
+import static app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour.SKIP_AUTOMATICALLY;
+import static app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour.SKIP_AUTOMATICALLY_ONCE;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import app.revanced.extension.shared.settings.BaseSettings;
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.EnumSetting;
+import app.revanced.extension.shared.settings.FloatSetting;
+import app.revanced.extension.shared.settings.IntegerSetting;
+import app.revanced.extension.shared.settings.LongSetting;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.settings.StringSetting;
+import app.revanced.extension.shared.settings.preference.SharedPrefCategory;
+import app.revanced.extension.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.DeArrowAvailability;
+import app.revanced.extension.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.StillImagesAvailability;
+import app.revanced.extension.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.ThumbnailOption;
+import app.revanced.extension.youtube.patches.alternativethumbnails.AlternativeThumbnailsPatch.ThumbnailStillTime;
+import app.revanced.extension.youtube.patches.general.ChangeStartPagePatch;
+import app.revanced.extension.youtube.patches.general.ChangeStartPagePatch.StartPage;
+import app.revanced.extension.youtube.patches.general.LayoutSwitchPatch.FormFactor;
+import app.revanced.extension.youtube.patches.general.YouTubeMusicActionsPatch;
+import app.revanced.extension.youtube.patches.misc.SpoofStreamingDataPatch;
+import app.revanced.extension.youtube.patches.misc.WatchHistoryPatch.WatchHistoryType;
+import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType;
+import app.revanced.extension.youtube.patches.shorts.AnimationFeedbackPatch.AnimationType;
+import app.revanced.extension.youtube.patches.utils.PatchStatus;
+import app.revanced.extension.youtube.shared.PlaylistIdPrefix;
+import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings;
+
+@SuppressWarnings("unused")
+public class Settings extends BaseSettings {
+ // PreferenceScreen: Ads
+ public static final BooleanSetting HIDE_GENERAL_ADS = new BooleanSetting("revanced_hide_general_ads", TRUE);
+ public static final BooleanSetting HIDE_GET_PREMIUM = new BooleanSetting("revanced_hide_get_premium", TRUE, true);
+ public static final BooleanSetting HIDE_MERCHANDISE_SHELF = new BooleanSetting("revanced_hide_merchandise_shelf", TRUE);
+ public static final BooleanSetting HIDE_PLAYER_STORE_SHELF = new BooleanSetting("revanced_hide_player_store_shelf", TRUE);
+ public static final BooleanSetting HIDE_PAID_PROMOTION_LABEL = new BooleanSetting("revanced_hide_paid_promotion_label", TRUE);
+ public static final BooleanSetting HIDE_SELF_SPONSOR_CARDS = new BooleanSetting("revanced_hide_self_sponsor_cards", TRUE);
+ public static final BooleanSetting HIDE_VIDEO_ADS = new BooleanSetting("revanced_hide_video_ads", TRUE, true);
+ public static final BooleanSetting HIDE_VIEW_PRODUCTS = new BooleanSetting("revanced_hide_view_products", TRUE);
+ public static final BooleanSetting HIDE_WEB_SEARCH_RESULTS = new BooleanSetting("revanced_hide_web_search_results", TRUE);
+
+
+ // PreferenceScreen: Alternative Thumbnails
+ public static final EnumSetting ALT_THUMBNAIL_HOME = new EnumSetting<>("revanced_alt_thumbnail_home", ThumbnailOption.ORIGINAL);
+ public static final EnumSetting ALT_THUMBNAIL_SUBSCRIPTIONS = new EnumSetting<>("revanced_alt_thumbnail_subscriptions", ThumbnailOption.ORIGINAL);
+ public static final EnumSetting ALT_THUMBNAIL_LIBRARY = new EnumSetting<>("revanced_alt_thumbnail_library", ThumbnailOption.ORIGINAL);
+ public static final EnumSetting ALT_THUMBNAIL_PLAYER = new EnumSetting<>("revanced_alt_thumbnail_player", ThumbnailOption.ORIGINAL);
+ public static final EnumSetting ALT_THUMBNAIL_SEARCH = new EnumSetting<>("revanced_alt_thumbnail_search", ThumbnailOption.ORIGINAL);
+ public static final StringSetting ALT_THUMBNAIL_DEARROW_API_URL = new StringSetting("revanced_alt_thumbnail_dearrow_api_url",
+ "https://dearrow-thumb.ajay.app/api/v1/getThumbnail", true, new DeArrowAvailability());
+ public static final BooleanSetting ALT_THUMBNAIL_DEARROW_CONNECTION_TOAST = new BooleanSetting("revanced_alt_thumbnail_dearrow_connection_toast", FALSE, new DeArrowAvailability());
+ public static final EnumSetting ALT_THUMBNAIL_STILLS_TIME = new EnumSetting<>("revanced_alt_thumbnail_stills_time", ThumbnailStillTime.MIDDLE, new StillImagesAvailability());
+ public static final BooleanSetting ALT_THUMBNAIL_STILLS_FAST = new BooleanSetting("revanced_alt_thumbnail_stills_fast", FALSE, new StillImagesAvailability());
+
+
+ // PreferenceScreen: Feed
+ public static final BooleanSetting HIDE_ALBUM_CARDS = new BooleanSetting("revanced_hide_album_card", TRUE);
+ public static final BooleanSetting HIDE_CAROUSEL_SHELF = new BooleanSetting("revanced_hide_carousel_shelf", FALSE, true);
+ public static final BooleanSetting HIDE_CHIPS_SHELF = new BooleanSetting("revanced_hide_chips_shelf", TRUE);
+ public static final BooleanSetting HIDE_EXPANDABLE_CHIP = new BooleanSetting("revanced_hide_expandable_chip", TRUE);
+ public static final BooleanSetting HIDE_EXPANDABLE_SHELF = new BooleanSetting("revanced_hide_expandable_shelf", TRUE);
+ public static final BooleanSetting HIDE_FEED_CAPTIONS_BUTTON = new BooleanSetting("revanced_hide_feed_captions_button", FALSE, true);
+ public static final BooleanSetting HIDE_FEED_SEARCH_BAR = new BooleanSetting("revanced_hide_feed_search_bar", FALSE);
+ public static final BooleanSetting HIDE_FEED_SURVEY = new BooleanSetting("revanced_hide_feed_survey", TRUE);
+ public static final BooleanSetting HIDE_FLOATING_BUTTON = new BooleanSetting("revanced_hide_floating_button", FALSE, true);
+ public static final BooleanSetting HIDE_IMAGE_SHELF = new BooleanSetting("revanced_hide_image_shelf", TRUE);
+ public static final BooleanSetting HIDE_LATEST_POSTS = new BooleanSetting("revanced_hide_latest_posts", TRUE);
+ public static final BooleanSetting HIDE_LATEST_VIDEOS_BUTTON = new BooleanSetting("revanced_hide_latest_videos_button", TRUE);
+ public static final BooleanSetting HIDE_MIX_PLAYLISTS = new BooleanSetting("revanced_hide_mix_playlists", FALSE);
+ public static final BooleanSetting HIDE_MOVIE_SHELF = new BooleanSetting("revanced_hide_movie_shelf", FALSE);
+ public static final BooleanSetting HIDE_NOTIFY_ME_BUTTON = new BooleanSetting("revanced_hide_notify_me_button", FALSE);
+ public static final BooleanSetting HIDE_PLAYABLES = new BooleanSetting("revanced_hide_playables", TRUE);
+ public static final BooleanSetting HIDE_SHOW_MORE_BUTTON = new BooleanSetting("revanced_hide_show_more_button", TRUE, true);
+ public static final BooleanSetting HIDE_SUBSCRIPTIONS_CAROUSEL = new BooleanSetting("revanced_hide_subscriptions_carousel", FALSE, true);
+ public static final BooleanSetting HIDE_TICKET_SHELF = new BooleanSetting("revanced_hide_ticket_shelf", TRUE);
+
+
+ // PreferenceScreen: Feed - Category bar
+ public static final BooleanSetting HIDE_CATEGORY_BAR_IN_FEED = new BooleanSetting("revanced_hide_category_bar_in_feed", FALSE, true);
+ public static final BooleanSetting HIDE_CATEGORY_BAR_IN_SEARCH = new BooleanSetting("revanced_hide_category_bar_in_search", FALSE, true);
+ public static final BooleanSetting HIDE_CATEGORY_BAR_IN_RELATED_VIDEOS = new BooleanSetting("revanced_hide_category_bar_in_related_videos", FALSE, true);
+
+ // PreferenceScreen: Feed - Channel profile
+ public static final BooleanSetting HIDE_CHANNEL_TAB = new BooleanSetting("revanced_hide_channel_tab", FALSE);
+ public static final StringSetting HIDE_CHANNEL_TAB_FILTER_STRINGS = new StringSetting("revanced_hide_channel_tab_filter_strings", "", true, parent(HIDE_CHANNEL_TAB));
+ public static final BooleanSetting HIDE_BROWSE_STORE_BUTTON = new BooleanSetting("revanced_hide_browse_store_button", TRUE);
+ public static final BooleanSetting HIDE_CHANNEL_MEMBER_SHELF = new BooleanSetting("revanced_hide_channel_member_shelf", TRUE);
+ public static final BooleanSetting HIDE_CHANNEL_PROFILE_LINKS = new BooleanSetting("revanced_hide_channel_profile_links", TRUE);
+ public static final BooleanSetting HIDE_FOR_YOU_SHELF = new BooleanSetting("revanced_hide_for_you_shelf", TRUE);
+
+ // PreferenceScreen: Feed - Community posts
+ public static final BooleanSetting HIDE_COMMUNITY_POSTS_CHANNEL = new BooleanSetting("revanced_hide_community_posts_channel", FALSE);
+ public static final BooleanSetting HIDE_COMMUNITY_POSTS_HOME_RELATED_VIDEOS = new BooleanSetting("revanced_hide_community_posts_home_related_videos", TRUE);
+ public static final BooleanSetting HIDE_COMMUNITY_POSTS_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_community_posts_subscriptions", FALSE);
+
+ // PreferenceScreen: Feed - Flyout menu
+ public static final BooleanSetting HIDE_FEED_FLYOUT_MENU = new BooleanSetting("revanced_hide_feed_flyout_menu", FALSE);
+ public static final StringSetting HIDE_FEED_FLYOUT_MENU_FILTER_STRINGS = new StringSetting("revanced_hide_feed_flyout_menu_filter_strings", "", true, parent(HIDE_FEED_FLYOUT_MENU));
+
+ // PreferenceScreen: Feed - Video filter
+ public static final BooleanSetting HIDE_KEYWORD_CONTENT_HOME = new BooleanSetting("revanced_hide_keyword_content_home", FALSE);
+ public static final BooleanSetting HIDE_KEYWORD_CONTENT_SEARCH = new BooleanSetting("revanced_hide_keyword_content_search", FALSE);
+ public static final BooleanSetting HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_keyword_content_subscriptions", FALSE);
+ public static final BooleanSetting HIDE_KEYWORD_CONTENT_COMMENTS = new BooleanSetting("revanced_hide_keyword_content_comments", FALSE);
+ public static final StringSetting HIDE_KEYWORD_CONTENT_PHRASES = new StringSetting("revanced_hide_keyword_content_phrases", "",
+ parentsAny(HIDE_KEYWORD_CONTENT_HOME, HIDE_KEYWORD_CONTENT_SEARCH, HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS, HIDE_KEYWORD_CONTENT_COMMENTS));
+
+ public static final BooleanSetting HIDE_RECOMMENDED_VIDEO = new BooleanSetting("revanced_hide_recommended_video", FALSE);
+ public static final BooleanSetting HIDE_LOW_VIEWS_VIDEO = new BooleanSetting("revanced_hide_low_views_video", TRUE);
+
+ public static final BooleanSetting HIDE_VIDEO_BY_VIEW_COUNTS_HOME = new BooleanSetting("revanced_hide_video_by_view_counts_home", FALSE);
+ public static final BooleanSetting HIDE_VIDEO_BY_VIEW_COUNTS_SEARCH = new BooleanSetting("revanced_hide_video_by_view_counts_search", FALSE);
+ public static final BooleanSetting HIDE_VIDEO_BY_VIEW_COUNTS_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_video_by_view_counts_subscriptions", FALSE);
+ public static final LongSetting HIDE_VIDEO_VIEW_COUNTS_LESS_THAN = new LongSetting("revanced_hide_video_view_counts_less_than", 1000L,
+ parentsAny(HIDE_VIDEO_BY_VIEW_COUNTS_HOME, HIDE_VIDEO_BY_VIEW_COUNTS_SEARCH, HIDE_VIDEO_BY_VIEW_COUNTS_SUBSCRIPTIONS));
+ public static final LongSetting HIDE_VIDEO_VIEW_COUNTS_GREATER_THAN = new LongSetting("revanced_hide_video_view_counts_greater_than", 1_000_000_000_000L,
+ parentsAny(HIDE_VIDEO_BY_VIEW_COUNTS_HOME, HIDE_VIDEO_BY_VIEW_COUNTS_SEARCH, HIDE_VIDEO_BY_VIEW_COUNTS_SUBSCRIPTIONS));
+ public static final StringSetting HIDE_VIDEO_VIEW_COUNTS_MULTIPLIER = new StringSetting("revanced_hide_video_view_counts_multiplier", str("revanced_hide_video_view_counts_multiplier_default_value"), true,
+ parentsAny(HIDE_VIDEO_BY_VIEW_COUNTS_HOME, HIDE_VIDEO_BY_VIEW_COUNTS_SEARCH, HIDE_VIDEO_BY_VIEW_COUNTS_SUBSCRIPTIONS));
+
+ // Experimental Flags
+ public static final BooleanSetting HIDE_RELATED_VIDEOS = new BooleanSetting("revanced_hide_related_videos", FALSE, true, "revanced_hide_related_videos_user_dialog_message");
+ public static final IntegerSetting RELATED_VIDEOS_OFFSET = new IntegerSetting("revanced_related_videos_offset", 2, true, parent(HIDE_RELATED_VIDEOS));
+
+
+ // PreferenceScreen: General
+ public static final EnumSetting CHANGE_START_PAGE = new EnumSetting<>("revanced_change_start_page", StartPage.ORIGINAL, true);
+ public static final BooleanSetting CHANGE_START_PAGE_TYPE = new BooleanSetting("revanced_change_start_page_type", FALSE, true,
+ new ChangeStartPagePatch.ChangeStartPageTypeAvailability());
+ public static final BooleanSetting DISABLE_AUTO_AUDIO_TRACKS = new BooleanSetting("revanced_disable_auto_audio_tracks", FALSE);
+ public static final BooleanSetting DISABLE_SPLASH_ANIMATION = new BooleanSetting("revanced_disable_splash_animation", FALSE, true);
+ public static final BooleanSetting ENABLE_GRADIENT_LOADING_SCREEN = new BooleanSetting("revanced_enable_gradient_loading_screen", FALSE, true);
+ public static final BooleanSetting HIDE_FLOATING_MICROPHONE = new BooleanSetting("revanced_hide_floating_microphone", TRUE, true);
+ public static final BooleanSetting HIDE_GRAY_SEPARATOR = new BooleanSetting("revanced_hide_gray_separator", TRUE);
+ public static final BooleanSetting HIDE_SNACK_BAR = new BooleanSetting("revanced_hide_snack_bar", FALSE);
+ public static final BooleanSetting REMOVE_VIEWER_DISCRETION_DIALOG = new BooleanSetting("revanced_remove_viewer_discretion_dialog", FALSE);
+
+ public static final EnumSetting CHANGE_LAYOUT = new EnumSetting<>("revanced_change_layout", FormFactor.ORIGINAL, true);
+ public static final BooleanSetting SPOOF_APP_VERSION = new BooleanSetting("revanced_spoof_app_version", false, true, "revanced_spoof_app_version_user_dialog_message");
+ public static final StringSetting SPOOF_APP_VERSION_TARGET = new StringSetting("revanced_spoof_app_version_target", "18.17.43", true, parent(SPOOF_APP_VERSION));
+
+ // PreferenceScreen: General - Account menu
+ public static final BooleanSetting HIDE_ACCOUNT_MENU = new BooleanSetting("revanced_hide_account_menu", FALSE);
+ public static final StringSetting HIDE_ACCOUNT_MENU_FILTER_STRINGS = new StringSetting("revanced_hide_account_menu_filter_strings", "", true, parent(HIDE_ACCOUNT_MENU));
+ public static final BooleanSetting HIDE_HANDLE = new BooleanSetting("revanced_hide_handle", TRUE, true);
+
+ // PreferenceScreen: General - Custom filter
+ public static final BooleanSetting CUSTOM_FILTER = new BooleanSetting("revanced_custom_filter", FALSE);
+ public static final StringSetting CUSTOM_FILTER_STRINGS = new StringSetting("revanced_custom_filter_strings", "", true, parent(CUSTOM_FILTER));
+
+ // PreferenceScreen: General - Miniplayer
+ public static final EnumSetting MINIPLAYER_TYPE = new EnumSetting<>("revanced_miniplayer_type", MiniplayerType.ORIGINAL, true);
+ public static final BooleanSetting MINIPLAYER_DOUBLE_TAP_ACTION = new BooleanSetting("revanced_miniplayer_enable_double_tap_action", FALSE, true, MINIPLAYER_TYPE.availability(MODERN_1, MODERN_2, MODERN_3));
+ public static final BooleanSetting MINIPLAYER_DRAG_AND_DROP = new BooleanSetting("revanced_miniplayer_enable_drag_and_drop", FALSE, true, MINIPLAYER_TYPE.availability(MODERN_1));
+ public static final BooleanSetting MINIPLAYER_HIDE_EXPAND_CLOSE = new BooleanSetting("revanced_miniplayer_hide_expand_close", FALSE, true);
+ public static final BooleanSetting MINIPLAYER_HIDE_SUBTEXT = new BooleanSetting("revanced_miniplayer_hide_subtext", FALSE, true, MINIPLAYER_TYPE.availability(MODERN_1, MODERN_3));
+ public static final BooleanSetting MINIPLAYER_HIDE_REWIND_FORWARD = new BooleanSetting("revanced_miniplayer_hide_rewind_forward", FALSE, true, MINIPLAYER_TYPE.availability(MODERN_1));
+ public static final IntegerSetting MINIPLAYER_OPACITY = new IntegerSetting("revanced_miniplayer_opacity", 100, true, MINIPLAYER_TYPE.availability(MODERN_1));
+
+ // PreferenceScreen: General - Navigation bar
+ public static final BooleanSetting ENABLE_NARROW_NAVIGATION_BUTTONS = new BooleanSetting("revanced_enable_narrow_navigation_buttons", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_CREATE_BUTTON = new BooleanSetting("revanced_hide_navigation_create_button", TRUE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_HOME_BUTTON = new BooleanSetting("revanced_hide_navigation_home_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_LIBRARY_BUTTON = new BooleanSetting("revanced_hide_navigation_library_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_NOTIFICATIONS_BUTTON = new BooleanSetting("revanced_hide_navigation_notifications_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_SHORTS_BUTTON = new BooleanSetting("revanced_hide_navigation_shorts_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_SUBSCRIPTIONS_BUTTON = new BooleanSetting("revanced_hide_navigation_subscriptions_button", FALSE, true);
+ public static final BooleanSetting HIDE_NAVIGATION_LABEL = new BooleanSetting("revanced_hide_navigation_label", FALSE, true);
+ public static final BooleanSetting 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);
+ public static final BooleanSetting HIDE_NAVIGATION_BAR = new BooleanSetting("revanced_hide_navigation_bar", FALSE, true);
+
+ // PreferenceScreen: General - Override buttons
+ 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);
+ public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME_VIDEO = new StringSetting("revanced_external_downloader_package_name_video", "com.deniscerri.ytdl");
+ public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME_PLAYLIST = new StringSetting("revanced_external_downloader_package_name_playlist", "com.deniscerri.ytdl");
+ public static final BooleanSetting OVERRIDE_YOUTUBE_MUSIC_BUTTON = new BooleanSetting("revanced_override_youtube_music_button", FALSE, true
+ , new YouTubeMusicActionsPatch.HookYouTubeMusicAvailability());
+ public static final StringSetting THIRD_PARTY_YOUTUBE_MUSIC_PACKAGE_NAME = new StringSetting("revanced_third_party_youtube_music_package_name", PatchStatus.RVXMusicPackageName(), true
+ , new YouTubeMusicActionsPatch.HookYouTubeMusicPackageNameAvailability());
+
+ // PreferenceScreen: General - Settings menu
+ public static final BooleanSetting HIDE_SETTINGS_MENU_PARENT_TOOLS = new BooleanSetting("revanced_hide_settings_menu_parent_tools", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_GENERAL = new BooleanSetting("revanced_hide_settings_menu_general", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_ACCOUNT = new BooleanSetting("revanced_hide_settings_menu_account", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_DATA_SAVING = new BooleanSetting("revanced_hide_settings_menu_data_saving", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_AUTOPLAY = new BooleanSetting("revanced_hide_settings_menu_auto_play", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_VIDEO_QUALITY_PREFERENCES = new BooleanSetting("revanced_hide_settings_menu_video_quality", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_OFFLINE = new BooleanSetting("revanced_hide_settings_menu_offline", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_WATCH_ON_TV = new BooleanSetting("revanced_hide_settings_menu_pair_with_tv", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_MANAGE_ALL_HISTORY = new BooleanSetting("revanced_hide_settings_menu_history", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_YOUR_DATA_IN_YOUTUBE = new BooleanSetting("revanced_hide_settings_menu_your_data", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_PRIVACY = new BooleanSetting("revanced_hide_settings_menu_privacy", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_TRY_EXPERIMENTAL_NEW_FEATURES = new BooleanSetting("revanced_hide_settings_menu_premium_early_access", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_PURCHASES_AND_MEMBERSHIPS = new BooleanSetting("revanced_hide_settings_menu_subscription_product", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_BILLING_AND_PAYMENTS = new BooleanSetting("revanced_hide_settings_menu_billing_and_payment", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_NOTIFICATIONS = new BooleanSetting("revanced_hide_settings_menu_notification", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_CONNECTED_APPS = new BooleanSetting("revanced_hide_settings_menu_connected_accounts", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_LIVE_CHAT = new BooleanSetting("revanced_hide_settings_menu_live_chat", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_CAPTIONS = new BooleanSetting("revanced_hide_settings_menu_captions", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_ACCESSIBILITY = new BooleanSetting("revanced_hide_settings_menu_accessibility", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_ABOUT = new BooleanSetting("revanced_hide_settings_menu_about", FALSE, true);
+ // dummy data
+ public static final BooleanSetting HIDE_SETTINGS_MENU_YOUTUBE_TV = new BooleanSetting("revanced_hide_settings_menu_youtube_tv", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_PRE_PURCHASE = new BooleanSetting("revanced_hide_settings_menu_pre_purchase", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_POST_PURCHASE = new BooleanSetting("revanced_hide_settings_menu_post_purchase", FALSE, true);
+ public static final BooleanSetting HIDE_SETTINGS_MENU_THIRD_PARTY = new BooleanSetting("revanced_hide_settings_menu_third_party", FALSE, true);
+
+ // PreferenceScreen: General - Toolbar
+ public static final BooleanSetting CHANGE_YOUTUBE_HEADER = new BooleanSetting("revanced_change_youtube_header", TRUE, true);
+ public static final BooleanSetting ENABLE_WIDE_SEARCH_BAR = new BooleanSetting("revanced_enable_wide_search_bar", FALSE, true);
+ public static final BooleanSetting ENABLE_WIDE_SEARCH_BAR_WITH_HEADER = new BooleanSetting("revanced_enable_wide_search_bar_with_header", TRUE, true);
+ public static final BooleanSetting ENABLE_WIDE_SEARCH_BAR_IN_YOU_TAB = new BooleanSetting("revanced_enable_wide_search_bar_in_you_tab", FALSE, true);
+ public static final BooleanSetting HIDE_TOOLBAR_CAST_BUTTON = new BooleanSetting("revanced_hide_toolbar_cast_button", TRUE, true);
+ public static final BooleanSetting HIDE_TOOLBAR_CREATE_BUTTON = new BooleanSetting("revanced_hide_toolbar_create_button", FALSE, true);
+ public static final BooleanSetting HIDE_TOOLBAR_NOTIFICATION_BUTTON = new BooleanSetting("revanced_hide_toolbar_notification_button", FALSE, true);
+ public static final BooleanSetting HIDE_SEARCH_TERM_THUMBNAIL = new BooleanSetting("revanced_hide_search_term_thumbnail", FALSE);
+ public static final BooleanSetting HIDE_IMAGE_SEARCH_BUTTON = new BooleanSetting("revanced_hide_image_search_button", FALSE, true);
+ public static final BooleanSetting HIDE_VOICE_SEARCH_BUTTON = new BooleanSetting("revanced_hide_voice_search_button", FALSE, true);
+ public static final BooleanSetting HIDE_YOUTUBE_DOODLES = new BooleanSetting("revanced_hide_youtube_doodles", FALSE, true, "revanced_hide_youtube_doodles_user_dialog_message");
+ public static final BooleanSetting REPLACE_TOOLBAR_CREATE_BUTTON = new BooleanSetting("revanced_replace_toolbar_create_button", FALSE, true);
+ public static final BooleanSetting REPLACE_TOOLBAR_CREATE_BUTTON_TYPE = new BooleanSetting("revanced_replace_toolbar_create_button_type", FALSE, true);
+
+
+ // PreferenceScreen: Player
+ public static final IntegerSetting CUSTOM_PLAYER_OVERLAY_OPACITY = new IntegerSetting("revanced_custom_player_overlay_opacity", 100, true);
+ public static final BooleanSetting DISABLE_AUTO_PLAYER_POPUP_PANELS = new BooleanSetting("revanced_disable_auto_player_popup_panels", TRUE, true);
+ public static final BooleanSetting DISABLE_AUTO_SWITCH_MIX_PLAYLISTS = new BooleanSetting("revanced_disable_auto_switch_mix_playlists", FALSE, true, "revanced_disable_auto_switch_mix_playlists_user_dialog_message");
+ public static final BooleanSetting DISABLE_SPEED_OVERLAY = new BooleanSetting("revanced_disable_speed_overlay", FALSE, true);
+ public static final FloatSetting SPEED_OVERLAY_VALUE = new FloatSetting("revanced_speed_overlay_value", 2.0f, true);
+ public static final BooleanSetting HIDE_CHANNEL_WATERMARK = new BooleanSetting("revanced_hide_channel_watermark", TRUE);
+ public static final BooleanSetting HIDE_CROWDFUNDING_BOX = new BooleanSetting("revanced_hide_crowdfunding_box", TRUE, true);
+ public static final BooleanSetting HIDE_DOUBLE_TAP_OVERLAY_FILTER = new BooleanSetting("revanced_hide_double_tap_overlay_filter", FALSE, true);
+ public static final BooleanSetting HIDE_END_SCREEN_CARDS = new BooleanSetting("revanced_hide_end_screen_cards", FALSE, true);
+ public static final BooleanSetting HIDE_FILMSTRIP_OVERLAY = new BooleanSetting("revanced_hide_filmstrip_overlay", FALSE, true);
+ public static final BooleanSetting HIDE_INFO_CARDS = new BooleanSetting("revanced_hide_info_cards", FALSE, true);
+ public static final BooleanSetting HIDE_INFO_PANEL = new BooleanSetting("revanced_hide_info_panel", TRUE);
+ public static final BooleanSetting HIDE_LIVE_CHAT_MESSAGES = new BooleanSetting("revanced_hide_live_chat_messages", FALSE);
+ public static final BooleanSetting HIDE_MEDICAL_PANEL = new BooleanSetting("revanced_hide_medical_panel", TRUE);
+ public static final BooleanSetting HIDE_SEEK_MESSAGE = new BooleanSetting("revanced_hide_seek_message", FALSE, true);
+ public static final BooleanSetting HIDE_SEEK_UNDO_MESSAGE = new BooleanSetting("revanced_hide_seek_undo_message", FALSE, true);
+ public static final BooleanSetting HIDE_SUGGESTED_ACTION = new BooleanSetting("revanced_hide_suggested_actions", TRUE, true);
+ public static final BooleanSetting HIDE_TIMED_REACTIONS = new BooleanSetting("revanced_hide_timed_reactions", TRUE);
+ public static final BooleanSetting HIDE_SUGGESTED_VIDEO_END_SCREEN = new BooleanSetting("revanced_hide_suggested_video_end_screen", TRUE, true);
+ public static final BooleanSetting SKIP_AUTOPLAY_COUNTDOWN = new BooleanSetting("revanced_skip_autoplay_countdown", FALSE, true, parent(HIDE_SUGGESTED_VIDEO_END_SCREEN));
+ public static final BooleanSetting HIDE_ZOOM_OVERLAY = new BooleanSetting("revanced_hide_zoom_overlay", FALSE, true);
+ public static final BooleanSetting SANITIZE_VIDEO_SUBTITLE = new BooleanSetting("revanced_sanitize_video_subtitle", FALSE);
+
+
+ // PreferenceScreen: Player - Action buttons
+ public static final BooleanSetting DISABLE_LIKE_DISLIKE_GLOW = new BooleanSetting("revanced_disable_like_dislike_glow", FALSE);
+ public static final BooleanSetting HIDE_CLIP_BUTTON = new BooleanSetting("revanced_hide_clip_button", FALSE);
+ public static final BooleanSetting HIDE_DOWNLOAD_BUTTON = new BooleanSetting("revanced_hide_download_button", FALSE);
+ public static final BooleanSetting HIDE_LIKE_DISLIKE_BUTTON = new BooleanSetting("revanced_hide_like_dislike_button", FALSE);
+ public static final BooleanSetting HIDE_PLAYLIST_BUTTON = new BooleanSetting("revanced_hide_playlist_button", FALSE);
+ public static final BooleanSetting HIDE_REMIX_BUTTON = new BooleanSetting("revanced_hide_remix_button", FALSE);
+ public static final BooleanSetting HIDE_REWARDS_BUTTON = new BooleanSetting("revanced_hide_rewards_button", FALSE);
+ public static final BooleanSetting HIDE_REPORT_BUTTON = new BooleanSetting("revanced_hide_report_button", FALSE);
+ public static final BooleanSetting HIDE_SHARE_BUTTON = new BooleanSetting("revanced_hide_share_button", FALSE);
+ public static final BooleanSetting HIDE_SHOP_BUTTON = new BooleanSetting("revanced_hide_shop_button", FALSE);
+ public static final BooleanSetting HIDE_THANKS_BUTTON = new BooleanSetting("revanced_hide_thanks_button", FALSE);
+
+ // PreferenceScreen: Player - Ambient mode
+ public static final BooleanSetting BYPASS_AMBIENT_MODE_RESTRICTIONS = new BooleanSetting("revanced_bypass_ambient_mode_restrictions", FALSE);
+ public static final BooleanSetting DISABLE_AMBIENT_MODE = new BooleanSetting("revanced_disable_ambient_mode", FALSE, true);
+ public static final BooleanSetting DISABLE_AMBIENT_MODE_IN_FULLSCREEN = new BooleanSetting("revanced_disable_ambient_mode_in_fullscreen", FALSE, true);
+
+ // PreferenceScreen: Player - Channel bar
+ public static final BooleanSetting HIDE_JOIN_BUTTON = new BooleanSetting("revanced_hide_join_button", TRUE);
+ public static final BooleanSetting HIDE_START_TRIAL_BUTTON = new BooleanSetting("revanced_hide_start_trial_button", TRUE);
+
+ // PreferenceScreen: Player - Comments
+ public static final BooleanSetting HIDE_CHANNEL_GUIDELINES = new BooleanSetting("revanced_hide_channel_guidelines", TRUE);
+ public static final BooleanSetting HIDE_COMMENTS_BY_MEMBERS = new BooleanSetting("revanced_hide_comments_by_members", FALSE);
+ public static final BooleanSetting HIDE_COMMENT_HIGHLIGHTED_SEARCH_LINKS = new BooleanSetting("revanced_hide_comment_highlighted_search_links", FALSE, true);
+ public static final BooleanSetting HIDE_COMMENTS_SECTION = new BooleanSetting("revanced_hide_comments_section", FALSE);
+ public static final BooleanSetting HIDE_COMMENTS_SECTION_IN_HOME_FEED = new BooleanSetting("revanced_hide_comments_section_in_home_feed", FALSE);
+ public static final BooleanSetting HIDE_PREVIEW_COMMENT = new BooleanSetting("revanced_hide_preview_comment", FALSE);
+ public static final BooleanSetting HIDE_PREVIEW_COMMENT_TYPE = new BooleanSetting("revanced_hide_preview_comment_type", FALSE);
+ public static final BooleanSetting HIDE_PREVIEW_COMMENT_OLD_METHOD = new BooleanSetting("revanced_hide_preview_comment_old_method", FALSE);
+ public static final BooleanSetting HIDE_PREVIEW_COMMENT_NEW_METHOD = new BooleanSetting("revanced_hide_preview_comment_new_method", FALSE);
+ public static final BooleanSetting HIDE_COMMENT_CREATE_SHORTS_BUTTON = new BooleanSetting("revanced_hide_comment_create_shorts_button", FALSE);
+ public static final BooleanSetting HIDE_COMMENT_THANKS_BUTTON = new BooleanSetting("revanced_hide_comment_thanks_button", FALSE, true);
+ public static final BooleanSetting HIDE_COMMENT_TIMESTAMP_AND_EMOJI_BUTTONS = new BooleanSetting("revanced_hide_comment_timestamp_and_emoji_buttons", FALSE);
+
+ // PreferenceScreen: Player - Flyout menu
+ public static final BooleanSetting CHANGE_PLAYER_FLYOUT_MENU_TOGGLE = new BooleanSetting("revanced_change_player_flyout_menu_toggle", FALSE, true);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_ENHANCED_BITRATE = new BooleanSetting("revanced_hide_player_flyout_menu_enhanced_bitrate", TRUE, true);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_AUDIO_TRACK = new BooleanSetting("revanced_hide_player_flyout_menu_audio_track", FALSE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_CAPTIONS = new BooleanSetting("revanced_hide_player_flyout_menu_captions", FALSE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_CAPTIONS_FOOTER = new BooleanSetting("revanced_hide_player_flyout_menu_captions_footer", TRUE, true);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_LOCK_SCREEN = new BooleanSetting("revanced_hide_player_flyout_menu_lock_screen", FALSE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_MORE = new BooleanSetting("revanced_hide_player_flyout_menu_more_info", FALSE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_PLAYBACK_SPEED = new BooleanSetting("revanced_hide_player_flyout_menu_playback_speed", FALSE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_QUALITY_HEADER = new BooleanSetting("revanced_hide_player_flyout_menu_quality_header", FALSE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_QUALITY_FOOTER = new BooleanSetting("revanced_hide_player_flyout_menu_quality_footer", TRUE, true);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_REPORT = new BooleanSetting("revanced_hide_player_flyout_menu_report", TRUE);
+
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_ADDITIONAL_SETTINGS = new BooleanSetting("revanced_hide_player_flyout_menu_additional_settings", FALSE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_AMBIENT = new BooleanSetting("revanced_hide_player_flyout_menu_ambient_mode", FALSE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_HELP = new BooleanSetting("revanced_hide_player_flyout_menu_help", TRUE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_LOOP = new BooleanSetting("revanced_hide_player_flyout_menu_loop_video", FALSE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_PIP = new BooleanSetting("revanced_hide_player_flyout_menu_pip", TRUE, true);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_PREMIUM_CONTROLS = new BooleanSetting("revanced_hide_player_flyout_menu_premium_controls", TRUE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_SLEEP_TIMER = new BooleanSetting("revanced_hide_player_flyout_menu_sleep_timer", TRUE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_STABLE_VOLUME = new BooleanSetting("revanced_hide_player_flyout_menu_stable_volume", FALSE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_STATS_FOR_NERDS = new BooleanSetting("revanced_hide_player_flyout_menu_stats_for_nerds", FALSE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_WATCH_IN_VR = new BooleanSetting("revanced_hide_player_flyout_menu_watch_in_vr", TRUE);
+ public static final BooleanSetting HIDE_PLAYER_FLYOUT_MENU_YT_MUSIC = new BooleanSetting("revanced_hide_player_flyout_menu_listen_with_youtube_music", TRUE);
+
+ // PreferenceScreen: Player - Fullscreen
+ public static final BooleanSetting DISABLE_ENGAGEMENT_PANEL = new BooleanSetting("revanced_disable_engagement_panel", FALSE, true);
+ public static final BooleanSetting SHOW_VIDEO_TITLE_SECTION = new BooleanSetting("revanced_show_video_title_section", TRUE, true, parent(DISABLE_ENGAGEMENT_PANEL));
+ public static final BooleanSetting HIDE_AUTOPLAY_PREVIEW = new BooleanSetting("revanced_hide_autoplay_preview", FALSE, true);
+ public static final BooleanSetting HIDE_LIVE_CHAT_REPLAY_BUTTON = new BooleanSetting("revanced_hide_live_chat_replay_button", FALSE);
+ public static final BooleanSetting HIDE_RELATED_VIDEO_OVERLAY = new BooleanSetting("revanced_hide_related_video_overlay", FALSE, true);
+
+ public static final BooleanSetting HIDE_QUICK_ACTIONS = new BooleanSetting("revanced_hide_quick_actions", FALSE, true);
+ public static final BooleanSetting HIDE_QUICK_ACTIONS_COMMENT_BUTTON = new BooleanSetting("revanced_hide_quick_actions_comment_button", FALSE);
+ public static final BooleanSetting HIDE_QUICK_ACTIONS_DISLIKE_BUTTON = new BooleanSetting("revanced_hide_quick_actions_dislike_button", FALSE);
+ public static final BooleanSetting HIDE_QUICK_ACTIONS_LIKE_BUTTON = new BooleanSetting("revanced_hide_quick_actions_like_button", FALSE);
+ public static final BooleanSetting HIDE_QUICK_ACTIONS_LIVE_CHAT_BUTTON = new BooleanSetting("revanced_hide_quick_actions_live_chat_button", FALSE);
+ public static final BooleanSetting HIDE_QUICK_ACTIONS_MORE_BUTTON = new BooleanSetting("revanced_hide_quick_actions_more_button", FALSE);
+ public static final BooleanSetting HIDE_QUICK_ACTIONS_OPEN_MIX_PLAYLIST_BUTTON = new BooleanSetting("revanced_hide_quick_actions_open_mix_playlist_button", FALSE);
+ public static final BooleanSetting HIDE_QUICK_ACTIONS_OPEN_PLAYLIST_BUTTON = new BooleanSetting("revanced_hide_quick_actions_open_playlist_button", FALSE);
+ public static final BooleanSetting HIDE_QUICK_ACTIONS_SAVE_TO_PLAYLIST_BUTTON = new BooleanSetting("revanced_hide_quick_actions_save_to_playlist_button", FALSE);
+ public static final BooleanSetting HIDE_QUICK_ACTIONS_SHARE_BUTTON = new BooleanSetting("revanced_hide_quick_actions_share_button", FALSE);
+ public static final IntegerSetting QUICK_ACTIONS_TOP_MARGIN = new IntegerSetting("revanced_quick_actions_top_margin", 0, true);
+
+ public static final BooleanSetting DISABLE_LANDSCAPE_MODE = new BooleanSetting("revanced_disable_landscape_mode", FALSE, true);
+ public static final BooleanSetting ENABLE_COMPACT_CONTROLS_OVERLAY = new BooleanSetting("revanced_enable_compact_controls_overlay", FALSE, true);
+ public static final BooleanSetting FORCE_FULLSCREEN = new BooleanSetting("revanced_force_fullscreen", FALSE, true);
+ public static final BooleanSetting KEEP_LANDSCAPE_MODE = new BooleanSetting("revanced_keep_landscape_mode", FALSE, true);
+ public static final LongSetting KEEP_LANDSCAPE_MODE_TIMEOUT = new LongSetting("revanced_keep_landscape_mode_timeout", 3000L, true);
+
+ // PreferenceScreen: Player - Haptic feedback
+ public static final BooleanSetting DISABLE_HAPTIC_FEEDBACK_CHAPTERS = new BooleanSetting("revanced_disable_haptic_feedback_chapters", FALSE);
+ public static final BooleanSetting DISABLE_HAPTIC_FEEDBACK_SCRUBBING = new BooleanSetting("revanced_disable_haptic_feedback_scrubbing", FALSE);
+ public static final BooleanSetting DISABLE_HAPTIC_FEEDBACK_SEEK = new BooleanSetting("revanced_disable_haptic_feedback_seek", FALSE);
+ public static final BooleanSetting DISABLE_HAPTIC_FEEDBACK_SEEK_UNDO = new BooleanSetting("revanced_disable_haptic_feedback_seek_undo", FALSE);
+ public static final BooleanSetting DISABLE_HAPTIC_FEEDBACK_ZOOM = new BooleanSetting("revanced_disable_haptic_feedback_zoom", FALSE);
+
+ // PreferenceScreen: Player - Player buttons
+ public static final BooleanSetting HIDE_PLAYER_AUTOPLAY_BUTTON = new BooleanSetting("revanced_hide_player_autoplay_button", TRUE, true);
+ public static final BooleanSetting HIDE_PLAYER_CAPTIONS_BUTTON = new BooleanSetting("revanced_hide_player_captions_button", FALSE, true);
+ public static final BooleanSetting HIDE_PLAYER_CAST_BUTTON = new BooleanSetting("revanced_hide_player_cast_button", TRUE, true);
+ public static final BooleanSetting HIDE_PLAYER_COLLAPSE_BUTTON = new BooleanSetting("revanced_hide_player_collapse_button", FALSE, true);
+ public static final BooleanSetting HIDE_PLAYER_FULLSCREEN_BUTTON = new BooleanSetting("revanced_hide_player_fullscreen_button", FALSE, true);
+ public static final BooleanSetting HIDE_PLAYER_PREVIOUS_NEXT_BUTTON = new BooleanSetting("revanced_hide_player_previous_next_button", FALSE, true);
+ public static final BooleanSetting HIDE_PLAYER_YOUTUBE_MUSIC_BUTTON = new BooleanSetting("revanced_hide_player_youtube_music_button", FALSE);
+
+ public static final BooleanSetting ALWAYS_REPEAT = new BooleanSetting("revanced_always_repeat", FALSE);
+ public static final BooleanSetting ALWAYS_REPEAT_PAUSE = new BooleanSetting("revanced_always_repeat_pause", FALSE);
+ public static final BooleanSetting OVERLAY_BUTTON_ALWAYS_REPEAT = new BooleanSetting("revanced_overlay_button_always_repeat", FALSE);
+ public static final BooleanSetting OVERLAY_BUTTON_COPY_VIDEO_URL = new BooleanSetting("revanced_overlay_button_copy_video_url", FALSE);
+ public static final BooleanSetting OVERLAY_BUTTON_COPY_VIDEO_URL_TIMESTAMP = new BooleanSetting("revanced_overlay_button_copy_video_url_timestamp", FALSE);
+ public static final BooleanSetting OVERLAY_BUTTON_MUTE_VOLUME = new BooleanSetting("revanced_overlay_button_mute_volume", FALSE);
+ public static final BooleanSetting OVERLAY_BUTTON_EXTERNAL_DOWNLOADER = new BooleanSetting("revanced_overlay_button_external_downloader", FALSE);
+ public static final BooleanSetting OVERLAY_BUTTON_SPEED_DIALOG = new BooleanSetting("revanced_overlay_button_speed_dialog", FALSE);
+ public static final BooleanSetting OVERLAY_BUTTON_PLAY_ALL = new BooleanSetting("revanced_overlay_button_play_all", FALSE);
+ public static final EnumSetting OVERLAY_BUTTON_PLAY_ALL_TYPE = new EnumSetting<>("revanced_overlay_button_play_all_type", PlaylistIdPrefix.ALL_CONTENTS_WITH_TIME_DESCENDING);
+ public static final BooleanSetting OVERLAY_BUTTON_WHITELIST = new BooleanSetting("revanced_overlay_button_whitelist", FALSE);
+
+ // PreferenceScreen: Player - Seekbar
+ public static final BooleanSetting APPEND_TIME_STAMP_INFORMATION = new BooleanSetting("revanced_append_time_stamp_information", TRUE, true);
+ public static final BooleanSetting APPEND_TIME_STAMP_INFORMATION_TYPE = new BooleanSetting("revanced_append_time_stamp_information_type", TRUE, parent(APPEND_TIME_STAMP_INFORMATION));
+ public static final BooleanSetting REPLACE_TIME_STAMP_ACTION = new BooleanSetting("revanced_replace_time_stamp_action", TRUE, true, parent(APPEND_TIME_STAMP_INFORMATION));
+ public static final BooleanSetting ENABLE_CUSTOM_SEEKBAR_COLOR = new BooleanSetting("revanced_enable_custom_seekbar_color", FALSE, true);
+ public static final StringSetting ENABLE_CUSTOM_SEEKBAR_COLOR_VALUE = new StringSetting("revanced_custom_seekbar_color_value", "#FF0000", true, parent(ENABLE_CUSTOM_SEEKBAR_COLOR));
+ public static final BooleanSetting ENABLE_SEEKBAR_TAPPING = new BooleanSetting("revanced_enable_seekbar_tapping", TRUE);
+ public static final BooleanSetting HIDE_SEEKBAR = new BooleanSetting("revanced_hide_seekbar", FALSE, true);
+ public static final BooleanSetting HIDE_SEEKBAR_THUMBNAIL = new BooleanSetting("revanced_hide_seekbar_thumbnail", FALSE);
+ public static final BooleanSetting DISABLE_SEEKBAR_CHAPTERS = new BooleanSetting("revanced_disable_seekbar_chapters", FALSE, true);
+ public static final BooleanSetting HIDE_SEEKBAR_CHAPTER_LABEL = new BooleanSetting("revanced_hide_seekbar_chapter_label", FALSE, true);
+ public static final BooleanSetting HIDE_TIME_STAMP = new BooleanSetting("revanced_hide_time_stamp", FALSE, true);
+ public static final BooleanSetting RESTORE_OLD_SEEKBAR_THUMBNAILS = new BooleanSetting("revanced_restore_old_seekbar_thumbnails",
+ PatchStatus.OldSeekbarThumbnailsDefaultBoolean(), true);
+ public static final BooleanSetting ENABLE_SEEKBAR_THUMBNAILS_HIGH_QUALITY = new BooleanSetting("revanced_enable_seekbar_thumbnails_high_quality", FALSE, true, "revanced_enable_seekbar_thumbnails_high_quality_dialog_message");
+ public static final BooleanSetting ENABLE_CAIRO_SEEKBAR = new BooleanSetting("revanced_enable_cairo_seekbar", FALSE, true);
+
+ // PreferenceScreen: Player - Video description
+ public static final BooleanSetting DISABLE_ROLLING_NUMBER_ANIMATIONS = new BooleanSetting("revanced_disable_rolling_number_animations", FALSE);
+ public static final BooleanSetting HIDE_AI_GENERATED_VIDEO_SUMMARY_SECTION = new BooleanSetting("revanced_hide_ai_generated_video_summary_section", FALSE);
+ public static final BooleanSetting HIDE_ATTRIBUTES_SECTION = new BooleanSetting("revanced_hide_attributes_section", FALSE);
+ public static final BooleanSetting HIDE_CHAPTERS_SECTION = new BooleanSetting("revanced_hide_chapters_section", FALSE);
+ public static final BooleanSetting HIDE_CONTENTS_SECTION = new BooleanSetting("revanced_hide_contents_section", FALSE);
+ public static final BooleanSetting HIDE_INFO_CARDS_SECTION = new BooleanSetting("revanced_hide_info_cards_section", FALSE);
+ public static final BooleanSetting HIDE_KEY_CONCEPTS_SECTION = new BooleanSetting("revanced_hide_key_concepts_section", FALSE);
+ public static final BooleanSetting HIDE_PODCAST_SECTION = new BooleanSetting("revanced_hide_podcast_section", FALSE);
+ public static final BooleanSetting HIDE_SHOPPING_LINKS = new BooleanSetting("revanced_hide_shopping_links", TRUE);
+ public static final BooleanSetting HIDE_TRANSCRIPT_SECTION = new BooleanSetting("revanced_hide_transcript_section", FALSE);
+ public static final BooleanSetting DISABLE_VIDEO_DESCRIPTION_INTERACTION = new BooleanSetting("revanced_disable_video_description_interaction", FALSE, true);
+ public static final BooleanSetting EXPAND_VIDEO_DESCRIPTION = new BooleanSetting("revanced_expand_video_description", FALSE, true);
+ public static final StringSetting EXPAND_VIDEO_DESCRIPTION_STRINGS = new StringSetting("revanced_expand_video_description_strings", str("revanced_expand_video_description_strings_default_value"), true, parent(EXPAND_VIDEO_DESCRIPTION));
+
+
+ // PreferenceScreen: Shorts
+ public static final BooleanSetting DISABLE_RESUMING_SHORTS_PLAYER = new BooleanSetting("revanced_disable_resuming_shorts_player", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_FLOATING_BUTTON = new BooleanSetting("revanced_hide_shorts_floating_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SHELF = new BooleanSetting("revanced_hide_shorts_shelf", TRUE, true);
+ public static final BooleanSetting HIDE_SHORTS_SHELF_CHANNEL = new BooleanSetting("revanced_hide_shorts_shelf_channel", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_SHELF_HOME_RELATED_VIDEOS = new BooleanSetting("revanced_hide_shorts_shelf_home_related_videos", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SHELF_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_shorts_shelf_subscriptions", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SHELF_SEARCH = new BooleanSetting("revanced_hide_shorts_shelf_search", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SHELF_HISTORY = new BooleanSetting("revanced_hide_shorts_shelf_history", FALSE);
+ public static final IntegerSetting CHANGE_SHORTS_REPEAT_STATE = new IntegerSetting("revanced_change_shorts_repeat_state", 0);
+
+ // PreferenceScreen: Shorts - Shorts player components
+ public static final BooleanSetting HIDE_SHORTS_JOIN_BUTTON = new BooleanSetting("revanced_hide_shorts_join_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SUBSCRIBE_BUTTON = new BooleanSetting("revanced_hide_shorts_subscribe_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_PAUSED_HEADER = new BooleanSetting("revanced_hide_shorts_paused_header", FALSE, true);
+ public static final BooleanSetting HIDE_SHORTS_PAUSED_OVERLAY_BUTTONS = new BooleanSetting("revanced_hide_shorts_paused_overlay_buttons", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_TRENDS_BUTTON = new BooleanSetting("revanced_hide_shorts_trends_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SHOPPING_BUTTON = new BooleanSetting("revanced_hide_shorts_shopping_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_STICKERS = new BooleanSetting("revanced_hide_shorts_stickers", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_PAID_PROMOTION_LABEL = new BooleanSetting("revanced_hide_shorts_paid_promotion_label", TRUE, true);
+ public static final BooleanSetting HIDE_SHORTS_INFO_PANEL = new BooleanSetting("revanced_hide_shorts_info_panel", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_LIVE_HEADER = new BooleanSetting("revanced_hide_shorts_live_header", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_CHANNEL_BAR = new BooleanSetting("revanced_hide_shorts_channel_bar", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_VIDEO_TITLE = new BooleanSetting("revanced_hide_shorts_video_title", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_SOUND_METADATA_LABEL = new BooleanSetting("revanced_hide_shorts_sound_metadata_label", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_FULL_VIDEO_LINK_LABEL = new BooleanSetting("revanced_hide_shorts_full_video_link_label", TRUE);
+
+ // PreferenceScreen: Shorts - Shorts player components - Suggested actions
+ public static final BooleanSetting HIDE_SHORTS_GREEN_SCREEN_BUTTON = new BooleanSetting("revanced_hide_shorts_green_screen_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SAVE_MUSIC_BUTTON = new BooleanSetting("revanced_hide_shorts_save_music_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SHOP_BUTTON = new BooleanSetting("revanced_hide_shorts_shop_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SUPER_THANKS_BUTTON = new BooleanSetting("revanced_hide_shorts_super_thanks_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_USE_THIS_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_use_this_sound_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_USE_TEMPLATE_BUTTON = new BooleanSetting("revanced_hide_shorts_use_template_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_LOCATION_BUTTON = new BooleanSetting("revanced_hide_shorts_location_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_SEARCH_SUGGESTIONS_BUTTON = new BooleanSetting("revanced_hide_shorts_search_suggestions_button", TRUE);
+ public static final BooleanSetting HIDE_SHORTS_TAGGED_PRODUCTS = new BooleanSetting("revanced_hide_shorts_tagged_products", TRUE);
+
+ // PreferenceScreen: Shorts - Shorts player components - Action buttons
+ public static final BooleanSetting HIDE_SHORTS_LIKE_BUTTON = new BooleanSetting("revanced_hide_shorts_like_button", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_DISLIKE_BUTTON = new BooleanSetting("revanced_hide_shorts_dislike_button", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_COMMENTS_BUTTON = new BooleanSetting("revanced_hide_shorts_comments_button", FALSE);
+ public static final BooleanSetting HIDE_SHORTS_REMIX_BUTTON = new BooleanSetting("revanced_hide_shorts_remix_button", TRUE);
+ 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);
+
+ 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);
+
+
+ // Experimental Flags
+ public static final BooleanSetting ENABLE_TIME_STAMP = new BooleanSetting("revanced_enable_shorts_time_stamp", FALSE, true);
+ public static final BooleanSetting TIME_STAMP_CHANGE_REPEAT_STATE = new BooleanSetting("revanced_shorts_time_stamp_change_repeat_state", TRUE, true, parent(ENABLE_TIME_STAMP));
+ public static final IntegerSetting META_PANEL_BOTTOM_MARGIN = new IntegerSetting("revanced_shorts_meta_panel_bottom_margin", 32, true, parent(ENABLE_TIME_STAMP));
+ public static final BooleanSetting HIDE_SHORTS_TOOLBAR = new BooleanSetting("revanced_hide_shorts_toolbar", FALSE, true);
+ public static final BooleanSetting HIDE_SHORTS_NAVIGATION_BAR = new BooleanSetting("revanced_hide_shorts_navigation_bar", FALSE, true);
+ public static final IntegerSetting SHORTS_NAVIGATION_BAR_HEIGHT_PERCENTAGE = new IntegerSetting("revanced_shorts_navigation_bar_height_percentage", 45, true, parent(HIDE_SHORTS_NAVIGATION_BAR));
+ public static final BooleanSetting REPLACE_CHANNEL_HANDLE = new BooleanSetting("revanced_replace_channel_handle", FALSE, true);
+
+ // PreferenceScreen: Swipe controls
+ public static final BooleanSetting ENABLE_SWIPE_BRIGHTNESS = new BooleanSetting("revanced_enable_swipe_brightness", TRUE, true);
+ public static final BooleanSetting ENABLE_SWIPE_VOLUME = new BooleanSetting("revanced_enable_swipe_volume", TRUE, true);
+ public static final BooleanSetting ENABLE_SWIPE_LOWEST_VALUE_AUTO_BRIGHTNESS = new BooleanSetting("revanced_enable_swipe_lowest_value_auto_brightness", TRUE, parent(ENABLE_SWIPE_BRIGHTNESS));
+ public static final BooleanSetting ENABLE_SAVE_AND_RESTORE_BRIGHTNESS = new BooleanSetting("revanced_enable_save_and_restore_brightness", TRUE, true, parent(ENABLE_SWIPE_BRIGHTNESS));
+ public static final BooleanSetting ENABLE_SWIPE_PRESS_TO_ENGAGE = new BooleanSetting("revanced_enable_swipe_press_to_engage", FALSE, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME));
+ public static final BooleanSetting ENABLE_SWIPE_HAPTIC_FEEDBACK = new BooleanSetting("revanced_enable_swipe_haptic_feedback", TRUE, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME));
+ public static final BooleanSetting SWIPE_LOCK_MODE = new BooleanSetting("revanced_swipe_gestures_lock_mode", FALSE, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME));
+ public static final IntegerSetting SWIPE_MAGNITUDE_THRESHOLD = new IntegerSetting("revanced_swipe_magnitude_threshold", 0, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME));
+ public static final IntegerSetting SWIPE_OVERLAY_BACKGROUND_ALPHA = new IntegerSetting("revanced_swipe_overlay_background_alpha", 127, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME));
+ public static final IntegerSetting SWIPE_OVERLAY_TEXT_SIZE = new IntegerSetting("revanced_swipe_overlay_text_size", 20, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME));
+ public static final IntegerSetting SWIPE_OVERLAY_RECT_SIZE = new IntegerSetting("revanced_swipe_overlay_rect_size", 20, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME));
+ public static final LongSetting SWIPE_OVERLAY_TIMEOUT = new LongSetting("revanced_swipe_overlay_timeout", 500L, true, parentsAny(ENABLE_SWIPE_BRIGHTNESS, ENABLE_SWIPE_VOLUME));
+
+ public static final IntegerSetting SWIPE_BRIGHTNESS_SENSITIVITY = new IntegerSetting("revanced_swipe_brightness_sensitivity", 100, true, parent(ENABLE_SWIPE_BRIGHTNESS));
+ public static final IntegerSetting SWIPE_VOLUME_SENSITIVITY = new IntegerSetting("revanced_swipe_volume_sensitivity", 100, true, parent(ENABLE_SWIPE_VOLUME));
+ /**
+ * @noinspection DeprecatedIsStillUsed
+ */
+ @Deprecated // Patch is obsolete and no longer works with 19.09+
+ public static final BooleanSetting DISABLE_HDR_AUTO_BRIGHTNESS = new BooleanSetting("revanced_disable_hdr_auto_brightness", TRUE, true, parent(ENABLE_SWIPE_BRIGHTNESS));
+ public static final BooleanSetting ENABLE_SWIPE_TO_SWITCH_VIDEO = new BooleanSetting("revanced_enable_swipe_to_switch_video", FALSE, true);
+ public static final BooleanSetting ENABLE_WATCH_PANEL_GESTURES = new BooleanSetting("revanced_enable_watch_panel_gestures", FALSE, true);
+ public static final BooleanSetting SWIPE_BRIGHTNESS_AUTO = new BooleanSetting("revanced_swipe_brightness_auto", TRUE, false, false);
+ public static final FloatSetting SWIPE_BRIGHTNESS_VALUE = new FloatSetting("revanced_swipe_brightness_value", -1.0f, false, false);
+
+
+ // PreferenceScreen: Video
+ public static final FloatSetting DEFAULT_PLAYBACK_SPEED = new FloatSetting("revanced_default_playback_speed", -2.0f);
+ public static final IntegerSetting DEFAULT_VIDEO_QUALITY_MOBILE = new IntegerSetting("revanced_default_video_quality_mobile", -2);
+ public static final IntegerSetting DEFAULT_VIDEO_QUALITY_WIFI = new IntegerSetting("revanced_default_video_quality_wifi", -2);
+ public static final BooleanSetting DISABLE_HDR_VIDEO = new BooleanSetting("revanced_disable_hdr_video", FALSE, true);
+ public static final BooleanSetting DISABLE_DEFAULT_PLAYBACK_SPEED_LIVE = new BooleanSetting("revanced_disable_default_playback_speed_live", TRUE);
+ public static final BooleanSetting ENABLE_CUSTOM_PLAYBACK_SPEED = new BooleanSetting("revanced_enable_custom_playback_speed", FALSE, true);
+ public static final BooleanSetting CUSTOM_PLAYBACK_SPEED_MENU_TYPE = new BooleanSetting("revanced_custom_playback_speed_menu_type", FALSE, parent(ENABLE_CUSTOM_PLAYBACK_SPEED));
+ public static final StringSetting CUSTOM_PLAYBACK_SPEEDS = new StringSetting("revanced_custom_playback_speeds", "0.25\n0.5\n0.75\n1.0\n1.25\n1.5\n1.75\n2.0\n2.25\n2.5", true, parent(ENABLE_CUSTOM_PLAYBACK_SPEED));
+ public static final BooleanSetting REMEMBER_PLAYBACK_SPEED_LAST_SELECTED = new BooleanSetting("revanced_remember_playback_speed_last_selected", TRUE);
+ public static final BooleanSetting REMEMBER_PLAYBACK_SPEED_LAST_SELECTED_TOAST = new BooleanSetting("revanced_remember_playback_speed_last_selected_toast", TRUE, parent(REMEMBER_PLAYBACK_SPEED_LAST_SELECTED));
+ public static final BooleanSetting REMEMBER_VIDEO_QUALITY_LAST_SELECTED = new BooleanSetting("revanced_remember_video_quality_last_selected", TRUE);
+ public static final BooleanSetting REMEMBER_VIDEO_QUALITY_LAST_SELECTED_TOAST = new BooleanSetting("revanced_remember_video_quality_last_selected_toast", TRUE, parent(REMEMBER_VIDEO_QUALITY_LAST_SELECTED));
+ public static final BooleanSetting RESTORE_OLD_VIDEO_QUALITY_MENU = new BooleanSetting("revanced_restore_old_video_quality_menu", TRUE, true);
+ // Experimental Flags
+ public static final BooleanSetting DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC = new BooleanSetting("revanced_disable_default_playback_speed_music", FALSE, true);
+ public static final BooleanSetting ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS = new BooleanSetting("revanced_enable_default_playback_speed_shorts", FALSE);
+ public static final BooleanSetting SKIP_PRELOADED_BUFFER = new BooleanSetting("revanced_skip_preloaded_buffer", FALSE, true, "revanced_skip_preloaded_buffer_user_dialog_message");
+ public static final BooleanSetting SKIP_PRELOADED_BUFFER_TOAST = new BooleanSetting("revanced_skip_preloaded_buffer_toast", TRUE);
+ public static final BooleanSetting SPOOF_DEVICE_DIMENSIONS = new BooleanSetting("revanced_spoof_device_dimensions", FALSE, true);
+ public static final BooleanSetting DISABLE_VP9_CODEC = new BooleanSetting("revanced_disable_vp9_codec", FALSE, true);
+ public static final BooleanSetting REPLACE_AV1_CODEC = new BooleanSetting("revanced_replace_av1_codec", FALSE, true);
+ public static final BooleanSetting REJECT_AV1_CODEC = new BooleanSetting("revanced_reject_av1_codec", FALSE, true);
+
+
+ // PreferenceScreen: Miscellaneous
+ public static final BooleanSetting ENABLE_EXTERNAL_BROWSER = new BooleanSetting("revanced_enable_external_browser", TRUE, true);
+ public static final BooleanSetting ENABLE_OPEN_LINKS_DIRECTLY = new BooleanSetting("revanced_enable_open_links_directly", TRUE);
+ public static final BooleanSetting DISABLE_QUIC_PROTOCOL = new BooleanSetting("revanced_disable_quic_protocol", FALSE, true);
+
+ // Experimental Flags
+ public static final BooleanSetting CHANGE_SHARE_SHEET = new BooleanSetting("revanced_change_share_sheet", FALSE, true);
+ public static final BooleanSetting ENABLE_OPUS_CODEC = new BooleanSetting("revanced_enable_opus_codec", FALSE, true);
+
+ /**
+ * @noinspection DeprecatedIsStillUsed
+ */
+ @Deprecated
+ public static final LongSetting DOUBLE_BACK_TO_CLOSE_TIMEOUT = new LongSetting("revanced_double_back_to_close_timeout", 2000L);
+
+ // PreferenceScreen: Miscellaneous - Watch history
+ public static final EnumSetting WATCH_HISTORY_TYPE = new EnumSetting<>("revanced_watch_history_type", WatchHistoryType.REPLACE);
+
+ // PreferenceScreen: Miscellaneous - Spoof streaming data
+ // The order of the settings should not be changed otherwise the app may crash
+ public static final BooleanSetting SPOOF_STREAMING_DATA = new BooleanSetting("revanced_spoof_streaming_data", TRUE, true, "revanced_spoof_streaming_data_user_dialog_message");
+ public static final BooleanSetting SPOOF_STREAMING_DATA_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_streaming_data_ios_force_avc", FALSE, true,
+ "revanced_spoof_streaming_data_ios_force_avc_user_dialog_message", new SpoofStreamingDataPatch.iOSAvailability());
+ public static final BooleanSetting SPOOF_STREAMING_DATA_IOS_SKIP_LIVESTREAM_PLAYBACK = new BooleanSetting("revanced_spoof_streaming_data_ios_skip_livestream_playback", TRUE, true, new SpoofStreamingDataPatch.iOSAvailability());
+ public static final EnumSetting SPOOF_STREAMING_DATA_TYPE = new EnumSetting<>("revanced_spoof_streaming_data_type", ClientType.IOS, true, parent(SPOOF_STREAMING_DATA));
+ public static final BooleanSetting SPOOF_STREAMING_DATA_STATS_FOR_NERDS = new BooleanSetting("revanced_spoof_streaming_data_stats_for_nerds", TRUE, parent(SPOOF_STREAMING_DATA));
+
+ // PreferenceScreen: Return YouTube Dislike
+ public static final BooleanSetting RYD_ENABLED = new BooleanSetting("ryd_enabled", TRUE);
+ public static final StringSetting RYD_USER_ID = new StringSetting("ryd_user_id", "");
+ public static final BooleanSetting RYD_SHORTS = new BooleanSetting("ryd_shorts", TRUE, parent(RYD_ENABLED));
+ public static final BooleanSetting RYD_DISLIKE_PERCENTAGE = new BooleanSetting("ryd_dislike_percentage", FALSE, parent(RYD_ENABLED));
+ public static final BooleanSetting RYD_COMPACT_LAYOUT = new BooleanSetting("ryd_compact_layout", FALSE, parent(RYD_ENABLED));
+ public static final BooleanSetting RYD_ESTIMATED_LIKE = new BooleanSetting("ryd_estimated_like", FALSE, true, parent(RYD_ENABLED));
+ public static final BooleanSetting RYD_TOAST_ON_CONNECTION_ERROR = new BooleanSetting("ryd_toast_on_connection_error", FALSE, parent(RYD_ENABLED));
+
+
+ // PreferenceScreen: SponsorBlock
+ public static final BooleanSetting SB_ENABLED = new BooleanSetting("sb_enabled", TRUE);
+ /**
+ * Do not use directly, instead use {@link SponsorBlockSettings}
+ */
+ public static final StringSetting SB_PRIVATE_USER_ID = new StringSetting("sb_private_user_id_Do_Not_Share", "", parent(SB_ENABLED));
+ public static final IntegerSetting SB_CREATE_NEW_SEGMENT_STEP = new IntegerSetting("sb_create_new_segment_step", 150, parent(SB_ENABLED));
+ public static final BooleanSetting SB_VOTING_BUTTON = new BooleanSetting("sb_voting_button", FALSE, parent(SB_ENABLED));
+ public static final BooleanSetting SB_CREATE_NEW_SEGMENT = new BooleanSetting("sb_create_new_segment", FALSE, parent(SB_ENABLED));
+ public static final BooleanSetting SB_COMPACT_SKIP_BUTTON = new BooleanSetting("sb_compact_skip_button", FALSE, parent(SB_ENABLED));
+ public static final BooleanSetting SB_AUTO_HIDE_SKIP_BUTTON = new BooleanSetting("sb_auto_hide_skip_button", TRUE, parent(SB_ENABLED));
+ public static final BooleanSetting SB_TOAST_ON_SKIP = new BooleanSetting("sb_toast_on_skip", TRUE, parent(SB_ENABLED));
+ public static final BooleanSetting SB_TOAST_ON_CONNECTION_ERROR = new BooleanSetting("sb_toast_on_connection_error", FALSE, parent(SB_ENABLED));
+ public static final BooleanSetting SB_TRACK_SKIP_COUNT = new BooleanSetting("sb_track_skip_count", TRUE, parent(SB_ENABLED));
+ public static final FloatSetting SB_SEGMENT_MIN_DURATION = new FloatSetting("sb_min_segment_duration", 0F, parent(SB_ENABLED));
+ public static final BooleanSetting SB_VIDEO_LENGTH_WITHOUT_SEGMENTS = new BooleanSetting("sb_video_length_without_segments", FALSE, parent(SB_ENABLED));
+ public static final StringSetting SB_API_URL = new StringSetting("sb_api_url", "https://sponsor.ajay.app", parent(SB_ENABLED));
+ public static final BooleanSetting SB_USER_IS_VIP = new BooleanSetting("sb_user_is_vip", FALSE);
+ public static final IntegerSetting SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS = new IntegerSetting("sb_local_time_saved_number_segments", 0);
+ public static final LongSetting SB_LOCAL_TIME_SAVED_MILLISECONDS = new LongSetting("sb_local_time_saved_milliseconds", 0L);
+
+ public static final StringSetting SB_CATEGORY_SPONSOR = new StringSetting("sb_sponsor", SKIP_AUTOMATICALLY.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_SPONSOR_COLOR = new StringSetting("sb_sponsor_color", "#00D400");
+ public static final StringSetting SB_CATEGORY_SELF_PROMO = new StringSetting("sb_selfpromo", SKIP_AUTOMATICALLY.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_SELF_PROMO_COLOR = new StringSetting("sb_selfpromo_color", "#FFFF00");
+ public static final StringSetting SB_CATEGORY_INTERACTION = new StringSetting("sb_interaction", SKIP_AUTOMATICALLY_ONCE.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_INTERACTION_COLOR = new StringSetting("sb_interaction_color", "#CC00FF");
+ public static final StringSetting SB_CATEGORY_HIGHLIGHT = new StringSetting("sb_highlight", MANUAL_SKIP.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_HIGHLIGHT_COLOR = new StringSetting("sb_highlight_color", "#FF1684");
+ public static final StringSetting SB_CATEGORY_INTRO = new StringSetting("sb_intro", SKIP_AUTOMATICALLY_ONCE.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_INTRO_COLOR = new StringSetting("sb_intro_color", "#00FFFF");
+ public static final StringSetting SB_CATEGORY_OUTRO = new StringSetting("sb_outro", SKIP_AUTOMATICALLY_ONCE.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_OUTRO_COLOR = new StringSetting("sb_outro_color", "#0202ED");
+ public static final StringSetting SB_CATEGORY_PREVIEW = new StringSetting("sb_preview", SKIP_AUTOMATICALLY_ONCE.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_PREVIEW_COLOR = new StringSetting("sb_preview_color", "#008FD6");
+ public static final StringSetting SB_CATEGORY_FILLER = new StringSetting("sb_filler", SKIP_AUTOMATICALLY_ONCE.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_FILLER_COLOR = new StringSetting("sb_filler_color", "#7300FF");
+ public static final StringSetting SB_CATEGORY_MUSIC_OFFTOPIC = new StringSetting("sb_music_offtopic", MANUAL_SKIP.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_MUSIC_OFFTOPIC_COLOR = new StringSetting("sb_music_offtopic_color", "#FF9900");
+ public static final StringSetting SB_CATEGORY_UNSUBMITTED = new StringSetting("sb_unsubmitted", SKIP_AUTOMATICALLY.reVancedKeyValue);
+ public static final StringSetting SB_CATEGORY_UNSUBMITTED_COLOR = new StringSetting("sb_unsubmitted_color", "#FFFFFF");
+
+ // SB Setting not exported
+ public static final LongSetting SB_LAST_VIP_CHECK = new LongSetting("sb_last_vip_check", 0L, false, false);
+ public static final BooleanSetting SB_HIDE_EXPORT_WARNING = new BooleanSetting("sb_hide_export_warning", FALSE, false, false);
+ public static final BooleanSetting SB_SEEN_GUIDELINES = new BooleanSetting("sb_seen_guidelines", FALSE, false, false);
+
+ static {
+ // region Migration initialized
+ // Categories were previously saved without a 'sb_' key prefix, so they need an additional adjustment.
+ Set> sbCategories = new HashSet<>(Arrays.asList(
+ SB_CATEGORY_SPONSOR,
+ SB_CATEGORY_SPONSOR_COLOR,
+ SB_CATEGORY_SELF_PROMO,
+ SB_CATEGORY_SELF_PROMO_COLOR,
+ SB_CATEGORY_INTERACTION,
+ SB_CATEGORY_INTERACTION_COLOR,
+ SB_CATEGORY_HIGHLIGHT,
+ SB_CATEGORY_HIGHLIGHT_COLOR,
+ SB_CATEGORY_INTRO,
+ SB_CATEGORY_INTRO_COLOR,
+ SB_CATEGORY_OUTRO,
+ SB_CATEGORY_OUTRO_COLOR,
+ SB_CATEGORY_PREVIEW,
+ SB_CATEGORY_PREVIEW_COLOR,
+ SB_CATEGORY_FILLER,
+ SB_CATEGORY_FILLER_COLOR,
+ SB_CATEGORY_MUSIC_OFFTOPIC,
+ SB_CATEGORY_MUSIC_OFFTOPIC_COLOR,
+ SB_CATEGORY_UNSUBMITTED,
+ SB_CATEGORY_UNSUBMITTED_COLOR));
+
+ SharedPrefCategory ytPrefs = new SharedPrefCategory("youtube");
+ SharedPrefCategory rydPrefs = new SharedPrefCategory("ryd");
+ SharedPrefCategory sbPrefs = new SharedPrefCategory("sponsor-block");
+ for (Setting> setting : Setting.allLoadedSettings()) {
+ String key = setting.key;
+ if (setting.key.startsWith("sb_")) {
+ if (sbCategories.contains(setting)) {
+ key = key.substring(3); // Remove the "sb_" prefix, as old categories are saved without it.
+ }
+ migrateFromOldPreferences(sbPrefs, setting, key);
+ } else if (setting.key.startsWith("ryd_")) {
+ migrateFromOldPreferences(rydPrefs, setting, key);
+ } else {
+ migrateFromOldPreferences(ytPrefs, setting, key);
+ }
+ }
+ // endregion
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AboutYouTubeDataAPIPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AboutYouTubeDataAPIPreference.java
new file mode 100644
index 0000000000..c8e8bd0d51
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AboutYouTubeDataAPIPreference.java
@@ -0,0 +1,46 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import android.app.Activity;
+import android.content.Context;
+import android.preference.Preference;
+import android.util.AttributeSet;
+
+import app.revanced.extension.shared.settings.preference.YouTubeDataAPIDialogBuilder;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class AboutYouTubeDataAPIPreference extends Preference implements Preference.OnPreferenceClickListener {
+
+ private void init() {
+ setSelectable(true);
+ setOnPreferenceClickListener(this);
+ }
+
+ public AboutYouTubeDataAPIPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+
+ public AboutYouTubeDataAPIPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ public AboutYouTubeDataAPIPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public AboutYouTubeDataAPIPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ if (getContext() instanceof Activity mActivity) {
+ YouTubeDataAPIDialogBuilder.showDialog(mActivity);
+ }
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AlternativeThumbnailsAboutDeArrowPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AlternativeThumbnailsAboutDeArrowPreference.java
new file mode 100644
index 0000000000..e979e9acad
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/AlternativeThumbnailsAboutDeArrowPreference.java
@@ -0,0 +1,38 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.preference.Preference;
+import android.util.AttributeSet;
+
+/**
+ * Allows tapping the DeArrow about preference to open the DeArrow website.
+ */
+@SuppressWarnings({"unused", "deprecation"})
+public class AlternativeThumbnailsAboutDeArrowPreference extends Preference {
+ {
+ setOnPreferenceClickListener(pref -> {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(Uri.parse("https://dearrow.ajay.app"));
+ pref.getContext().startActivity(i);
+ return false;
+ });
+ }
+
+ public AlternativeThumbnailsAboutDeArrowPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public AlternativeThumbnailsAboutDeArrowPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public AlternativeThumbnailsAboutDeArrowPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public AlternativeThumbnailsAboutDeArrowPreference(Context context) {
+ super(context);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderPlaylistPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderPlaylistPreference.java
new file mode 100644
index 0000000000..547eb3e34e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderPlaylistPreference.java
@@ -0,0 +1,175 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.annotation.SuppressLint;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.preference.Preference;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TableLayout;
+import android.widget.TableRow;
+
+import java.util.Arrays;
+
+import app.revanced.extension.shared.settings.StringSetting;
+import app.revanced.extension.shared.utils.ResourceUtils;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.utils.ExtendedUtils;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class ExternalDownloaderPlaylistPreference extends Preference implements Preference.OnPreferenceClickListener {
+
+ private static final StringSetting settings = Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME_PLAYLIST;
+ private static final String[] mEntries = ResourceUtils.getStringArray("revanced_external_downloader_playlist_label");
+ private static final String[] mEntryValues = ResourceUtils.getStringArray("revanced_external_downloader_playlist_package_name");
+ private static final String[] mWebsiteEntries = ResourceUtils.getStringArray("revanced_external_downloader_playlist_website");
+
+ @SuppressLint("StaticFieldLeak")
+ private static EditText mEditText;
+ private static String packageName;
+ private static int mClickedDialogEntryIndex;
+
+ private final TextWatcher textWatcher = new TextWatcher() {
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ public void afterTextChanged(Editable s) {
+ packageName = s.toString();
+ mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName);
+ }
+ };
+
+ private void init() {
+ setSelectable(true);
+ setOnPreferenceClickListener(this);
+ }
+
+ public ExternalDownloaderPlaylistPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+
+ public ExternalDownloaderPlaylistPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ public ExternalDownloaderPlaylistPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public ExternalDownloaderPlaylistPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ packageName = settings.get();
+ mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName);
+
+ final Context context = getContext();
+ AlertDialog.Builder builder = Utils.getEditTextDialogBuilder(context);
+
+ TableLayout table = new TableLayout(context);
+ table.setOrientation(LinearLayout.HORIZONTAL);
+ table.setPadding(15, 0, 15, 0);
+
+ TableRow row = new TableRow(context);
+
+ mEditText = new EditText(context);
+ mEditText.setHint(settings.defaultValue);
+ mEditText.setText(packageName);
+ mEditText.addTextChangedListener(textWatcher);
+ mEditText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 9);
+ mEditText.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT, 1f));
+ row.addView(mEditText);
+
+ table.addView(row);
+ builder.setView(table);
+
+ builder.setTitle(str("revanced_external_downloader_dialog_title"));
+ builder.setSingleChoiceItems(mEntries, mClickedDialogEntryIndex, (dialog, which) -> {
+ mClickedDialogEntryIndex = which;
+ mEditText.setText(mEntryValues[which]);
+ });
+ builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ final String packageName = mEditText.getText().toString().trim();
+ settings.save(packageName);
+ checkPackageIsValid(context, packageName);
+ dialog.dismiss();
+ });
+ builder.setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> settings.resetToDefault());
+ builder.setNegativeButton(android.R.string.cancel, null);
+
+ builder.show();
+
+ return true;
+ }
+
+ private static boolean checkPackageIsValid(Context context, String packageName) {
+ String appName = "";
+ String website = "";
+
+ if (mClickedDialogEntryIndex >= 0) {
+ appName = mEntries[mClickedDialogEntryIndex];
+ website = mWebsiteEntries[mClickedDialogEntryIndex];
+ }
+
+ return showToastOrOpenWebsites(context, appName, packageName, website);
+ }
+
+ private static boolean showToastOrOpenWebsites(Context context, String appName, String packageName, String website) {
+ if (ExtendedUtils.isPackageEnabled(packageName))
+ return true;
+
+ if (website.isEmpty()) {
+ Utils.showToastShort(str("revanced_external_downloader_not_installed_warning", packageName));
+ return false;
+ }
+
+ new AlertDialog.Builder(context)
+ .setTitle(str("revanced_external_downloader_not_installed_dialog_title"))
+ .setMessage(str("revanced_external_downloader_not_installed_dialog_message", appName, appName))
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(website));
+ context.startActivity(i);
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+
+ return false;
+ }
+
+ public static boolean checkPackageIsDisabled() {
+ final Context context = Utils.getActivity();
+ packageName = settings.get();
+ mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName);
+ return !checkPackageIsValid(context, packageName);
+ }
+
+ public static String getExternalDownloaderPackageName() {
+ String downloaderPackageName = settings.get().trim();
+
+ if (downloaderPackageName.isEmpty()) {
+ settings.resetToDefault();
+ downloaderPackageName = settings.defaultValue;
+ }
+
+ return downloaderPackageName;
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderVideoPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderVideoPreference.java
new file mode 100644
index 0000000000..26a83143f0
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ExternalDownloaderVideoPreference.java
@@ -0,0 +1,175 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.annotation.SuppressLint;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.preference.Preference;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TableLayout;
+import android.widget.TableRow;
+
+import java.util.Arrays;
+
+import app.revanced.extension.shared.settings.StringSetting;
+import app.revanced.extension.shared.utils.ResourceUtils;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.utils.ExtendedUtils;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class ExternalDownloaderVideoPreference extends Preference implements Preference.OnPreferenceClickListener {
+
+ private static final StringSetting settings = Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME_VIDEO;
+ private static final String[] mEntries = ResourceUtils.getStringArray("revanced_external_downloader_video_label");
+ private static final String[] mEntryValues = ResourceUtils.getStringArray("revanced_external_downloader_video_package_name");
+ private static final String[] mWebsiteEntries = ResourceUtils.getStringArray("revanced_external_downloader_video_website");
+
+ @SuppressLint("StaticFieldLeak")
+ private static EditText mEditText;
+ private static String packageName;
+ private static int mClickedDialogEntryIndex;
+
+ private final TextWatcher textWatcher = new TextWatcher() {
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ public void afterTextChanged(Editable s) {
+ packageName = s.toString();
+ mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName);
+ }
+ };
+
+ private void init() {
+ setSelectable(true);
+ setOnPreferenceClickListener(this);
+ }
+
+ public ExternalDownloaderVideoPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+
+ public ExternalDownloaderVideoPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ public ExternalDownloaderVideoPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public ExternalDownloaderVideoPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ packageName = settings.get();
+ mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName);
+
+ final Context context = getContext();
+ AlertDialog.Builder builder = Utils.getEditTextDialogBuilder(context);
+
+ TableLayout table = new TableLayout(context);
+ table.setOrientation(LinearLayout.HORIZONTAL);
+ table.setPadding(15, 0, 15, 0);
+
+ TableRow row = new TableRow(context);
+
+ mEditText = new EditText(context);
+ mEditText.setHint(settings.defaultValue);
+ mEditText.setText(packageName);
+ mEditText.addTextChangedListener(textWatcher);
+ mEditText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 9);
+ mEditText.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT, 1f));
+ row.addView(mEditText);
+
+ table.addView(row);
+ builder.setView(table);
+
+ builder.setTitle(str("revanced_external_downloader_dialog_title"));
+ builder.setSingleChoiceItems(mEntries, mClickedDialogEntryIndex, (dialog, which) -> {
+ mClickedDialogEntryIndex = which;
+ mEditText.setText(mEntryValues[which]);
+ });
+ builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ final String packageName = mEditText.getText().toString().trim();
+ settings.save(packageName);
+ checkPackageIsValid(context, packageName);
+ dialog.dismiss();
+ });
+ builder.setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> settings.resetToDefault());
+ builder.setNegativeButton(android.R.string.cancel, null);
+
+ builder.show();
+
+ return true;
+ }
+
+ private static boolean checkPackageIsValid(Context context, String packageName) {
+ String appName = "";
+ String website = "";
+
+ if (mClickedDialogEntryIndex >= 0) {
+ appName = mEntries[mClickedDialogEntryIndex];
+ website = mWebsiteEntries[mClickedDialogEntryIndex];
+ }
+
+ return showToastOrOpenWebsites(context, appName, packageName, website);
+ }
+
+ private static boolean showToastOrOpenWebsites(Context context, String appName, String packageName, String website) {
+ if (ExtendedUtils.isPackageEnabled(packageName))
+ return true;
+
+ if (website.isEmpty()) {
+ Utils.showToastShort(str("revanced_external_downloader_not_installed_warning", packageName));
+ return false;
+ }
+
+ new AlertDialog.Builder(context)
+ .setTitle(str("revanced_external_downloader_not_installed_dialog_title"))
+ .setMessage(str("revanced_external_downloader_not_installed_dialog_message", appName, appName))
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ Intent i = new Intent(Intent.ACTION_VIEW, Uri.parse(website));
+ context.startActivity(i);
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+
+ return false;
+ }
+
+ public static boolean checkPackageIsDisabled() {
+ final Context context = Utils.getActivity();
+ packageName = settings.get();
+ mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName);
+ return !checkPackageIsValid(context, packageName);
+ }
+
+ public static String getExternalDownloaderPackageName() {
+ String downloaderPackageName = settings.get().trim();
+
+ if (downloaderPackageName.isEmpty()) {
+ settings.resetToDefault();
+ downloaderPackageName = settings.defaultValue;
+ }
+
+ return downloaderPackageName;
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ImportExportPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ImportExportPreference.java
new file mode 100644
index 0000000000..77c1c6b75c
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ImportExportPreference.java
@@ -0,0 +1,102 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.annotation.TargetApi;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.preference.EditTextPreference;
+import android.preference.Preference;
+import android.text.InputType;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.widget.EditText;
+
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class ImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener {
+
+ private String existingSettings;
+
+ @TargetApi(26)
+ private void init() {
+ setSelectable(true);
+
+ EditText editText = getEditText();
+ editText.setTextIsSelectable(true);
+ editText.setAutofillHints((String) null);
+ editText.setInputType(editText.getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ editText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 8); // Use a smaller font to reduce text wrap.
+
+ setOnPreferenceClickListener(this);
+ }
+
+ public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+
+ public ImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ public ImportExportPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public ImportExportPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ try {
+ // Must set text before preparing dialog, otherwise text is non selectable if this preference is later reopened.
+ existingSettings = Setting.exportToJson(null);
+ getEditText().setText(existingSettings);
+ } catch (Exception ex) {
+ Logger.printException(() -> "showDialog failure", ex);
+ }
+ return true;
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ try {
+ Utils.setEditTextDialogTheme(builder, true);
+ super.onPrepareDialogBuilder(builder);
+ // Show the user the settings in JSON format.
+ builder.setNeutralButton(str("revanced_extended_settings_import_copy"), (dialog, which) ->
+ Utils.setClipboard(getEditText().getText().toString(), str("revanced_share_copy_settings_success")))
+ .setPositiveButton(str("revanced_extended_settings_import"), (dialog, which) ->
+ importSettings(getEditText().getText().toString()));
+ } catch (Exception ex) {
+ Logger.printException(() -> "onPrepareDialogBuilder failure", ex);
+ }
+ }
+
+ private void importSettings(String replacementSettings) {
+ try {
+ if (replacementSettings.equals(existingSettings)) {
+ return;
+ }
+ ReVancedPreferenceFragment.settingImportInProgress = true;
+ final boolean rebootNeeded = Setting.importFromJSON(replacementSettings, true);
+ if (rebootNeeded) {
+ AbstractPreferenceFragment.showRestartDialog(getContext());
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "importSettings failure", ex);
+ } finally {
+ ReVancedPreferenceFragment.settingImportInProgress = false;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/OpenDefaultAppSettingsPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/OpenDefaultAppSettingsPreference.java
new file mode 100644
index 0000000000..169458053f
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/OpenDefaultAppSettingsPreference.java
@@ -0,0 +1,47 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.utils.Utils.isSDKAbove;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.preference.Preference;
+import android.util.AttributeSet;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class OpenDefaultAppSettingsPreference extends Preference {
+ {
+ setOnPreferenceClickListener(pref -> {
+ try {
+ Context context = Utils.getActivity();
+ final Uri uri = Uri.parse("package:" + context.getPackageName());
+ final Intent intent = isSDKAbove(31)
+ ? new Intent(android.provider.Settings.ACTION_APP_OPEN_BY_DEFAULT_SETTINGS, uri)
+ : new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS, uri);
+ context.startActivity(intent);
+ } catch (Exception exception) {
+ Logger.printException(() -> "OpenDefaultAppSettings Failed");
+ }
+ return false;
+ });
+ }
+
+ public OpenDefaultAppSettingsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public OpenDefaultAppSettingsPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public OpenDefaultAppSettingsPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public OpenDefaultAppSettingsPreference(Context context) {
+ super(context);
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java
new file mode 100644
index 0000000000..086243f9a6
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedPreferenceFragment.java
@@ -0,0 +1,689 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment.showRestartDialog;
+import static app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment.updateListPreferenceSummary;
+import static app.revanced.extension.shared.utils.ResourceUtils.getXmlIdentifier;
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.shared.utils.Utils.getChildView;
+import static app.revanced.extension.shared.utils.Utils.isSDKAbove;
+import static app.revanced.extension.shared.utils.Utils.showToastShort;
+import static app.revanced.extension.youtube.settings.Settings.DEFAULT_PLAYBACK_SPEED;
+import static app.revanced.extension.youtube.settings.Settings.HIDE_PREVIEW_COMMENT;
+import static app.revanced.extension.youtube.settings.Settings.HIDE_PREVIEW_COMMENT_TYPE;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.net.Uri;
+import android.os.Bundle;
+import android.preference.EditTextPreference;
+import android.preference.ListPreference;
+import android.preference.Preference;
+import android.preference.PreferenceCategory;
+import android.preference.PreferenceFragment;
+import android.preference.PreferenceGroup;
+import android.preference.PreferenceManager;
+import android.preference.PreferenceScreen;
+import android.preference.SwitchPreference;
+import android.util.TypedValue;
+import android.view.ViewGroup;
+import android.widget.TextView;
+import android.widget.Toolbar;
+
+import java.io.BufferedReader;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.Setting;
+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.video.CustomPlaybackSpeedPatch;
+import app.revanced.extension.youtube.utils.ExtendedUtils;
+import app.revanced.extension.youtube.utils.ThemeUtils;
+
+@SuppressWarnings("deprecation")
+public class ReVancedPreferenceFragment extends PreferenceFragment {
+ private static final int READ_REQUEST_CODE = 42;
+ private static final int WRITE_REQUEST_CODE = 43;
+ static boolean settingImportInProgress = false;
+ static boolean showingUserDialogMessage;
+
+ @SuppressLint("SuspiciousIndentation")
+ private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
+ try {
+ if (str == null) return;
+ Setting> setting = Setting.getSettingFromPath(str);
+
+ if (setting == null) return;
+
+ Preference mPreference = findPreference(str);
+
+ if (mPreference == null) return;
+
+ if (mPreference instanceof SwitchPreference switchPreference) {
+ BooleanSetting boolSetting = (BooleanSetting) setting;
+ if (settingImportInProgress) {
+ switchPreference.setChecked(boolSetting.get());
+ } else {
+ BooleanSetting.privateSetValue(boolSetting, switchPreference.isChecked());
+ }
+
+ if (ExtendedUtils.anyMatchSetting(setting)) {
+ ExtendedUtils.setPlayerFlyoutMenuAdditionalSettings();
+ } else if (setting.equals(HIDE_PREVIEW_COMMENT) || setting.equals(HIDE_PREVIEW_COMMENT_TYPE)) {
+ ExtendedUtils.setCommentPreviewSettings();
+ }
+ } else if (mPreference instanceof EditTextPreference editTextPreference) {
+ if (settingImportInProgress) {
+ editTextPreference.setText(setting.get().toString());
+ } else {
+ Setting.privateSetValueFromString(setting, editTextPreference.getText());
+ }
+ } else if (mPreference instanceof ListPreference listPreference) {
+ if (settingImportInProgress) {
+ listPreference.setValue(setting.get().toString());
+ } else {
+ Setting.privateSetValueFromString(setting, listPreference.getValue());
+ }
+ if (setting.equals(DEFAULT_PLAYBACK_SPEED)) {
+ listPreference.setEntries(CustomPlaybackSpeedPatch.getListEntries());
+ listPreference.setEntryValues(CustomPlaybackSpeedPatch.getListEntryValues());
+ }
+ if (!(mPreference instanceof app.revanced.extension.youtube.settings.preference.SegmentCategoryListPreference)) {
+ updateListPreferenceSummary(listPreference, setting);
+ }
+ } else {
+ Logger.printException(() -> "Setting cannot be handled: " + mPreference.getClass() + " " + mPreference);
+ return;
+ }
+
+ ReVancedSettingsPreference.initializeReVancedSettings();
+
+ if (settingImportInProgress) {
+ return;
+ }
+
+ if (!showingUserDialogMessage) {
+ final Context context = getActivity();
+
+ if (setting.userDialogMessage != null
+ && mPreference instanceof SwitchPreference switchPreference
+ && setting.defaultValue instanceof Boolean defaultValue
+ && switchPreference.isChecked() != defaultValue) {
+ showSettingUserDialogConfirmation(context, switchPreference, (BooleanSetting) setting);
+ } else if (setting.rebootApp) {
+ showRestartDialog(context);
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "OnSharedPreferenceChangeListener failure", ex);
+ }
+ };
+
+ private void showSettingUserDialogConfirmation(Context context, SwitchPreference switchPreference, BooleanSetting setting) {
+ Utils.verifyOnMainThread();
+
+ showingUserDialogMessage = true;
+ assert setting.userDialogMessage != null;
+ new AlertDialog.Builder(context)
+ .setTitle(str("revanced_extended_confirm_user_dialog_title"))
+ .setMessage(setting.userDialogMessage.toString())
+ .setPositiveButton(android.R.string.ok, (dialog, id) -> {
+ if (setting.rebootApp) {
+ showRestartDialog(context);
+ }
+ })
+ .setNegativeButton(android.R.string.cancel, (dialog, id) -> {
+ switchPreference.setChecked(setting.defaultValue); // Recursive call that resets the Setting value.
+ })
+ .setOnDismissListener(dialog -> showingUserDialogMessage = false)
+ .setCancelable(false)
+ .show();
+ }
+
+ static PreferenceManager mPreferenceManager;
+ private SharedPreferences mSharedPreferences;
+
+ private PreferenceScreen originalPreferenceScreen;
+
+ public ReVancedPreferenceFragment() {
+ // Required empty public constructor
+ }
+
+ private void putPreferenceScreenMap(SortedMap preferenceScreenMap, PreferenceGroup preferenceGroup) {
+ if (preferenceGroup instanceof PreferenceScreen mPreferenceScreen) {
+ preferenceScreenMap.put(mPreferenceScreen.getKey(), mPreferenceScreen);
+ }
+ }
+
+ private void setPreferenceScreenToolbar() {
+ SortedMap preferenceScreenMap = new TreeMap<>();
+
+ PreferenceScreen rootPreferenceScreen = getPreferenceScreen();
+ for (Preference preference : getAllPreferencesBy(rootPreferenceScreen)) {
+ if (!(preference instanceof PreferenceGroup preferenceGroup)) continue;
+ putPreferenceScreenMap(preferenceScreenMap, preferenceGroup);
+ for (Preference childPreference : getAllPreferencesBy(preferenceGroup)) {
+ if (!(childPreference instanceof PreferenceGroup nestedPreferenceGroup)) continue;
+ putPreferenceScreenMap(preferenceScreenMap, nestedPreferenceGroup);
+ for (Preference nestedPreference : getAllPreferencesBy(nestedPreferenceGroup)) {
+ if (!(nestedPreference instanceof PreferenceGroup childPreferenceGroup))
+ continue;
+ putPreferenceScreenMap(preferenceScreenMap, childPreferenceGroup);
+ }
+ }
+ }
+
+ for (PreferenceScreen mPreferenceScreen : preferenceScreenMap.values()) {
+ mPreferenceScreen.setOnPreferenceClickListener(
+ preferenceScreen -> {
+ Dialog preferenceScreenDialog = mPreferenceScreen.getDialog();
+ ViewGroup rootView = (ViewGroup) preferenceScreenDialog
+ .findViewById(android.R.id.content)
+ .getParent();
+
+ Toolbar toolbar = new Toolbar(preferenceScreen.getContext());
+
+ toolbar.setTitle(preferenceScreen.getTitle());
+ toolbar.setNavigationIcon(ThemeUtils.getBackButtonDrawable());
+ toolbar.setNavigationOnClickListener(view -> preferenceScreenDialog.dismiss());
+
+ int margin = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, 16, getResources().getDisplayMetrics()
+ );
+
+ toolbar.setTitleMargin(margin, 0, margin, 0);
+
+ TextView toolbarTextView = getChildView(toolbar, TextView.class::isInstance);
+ if (toolbarTextView != null) {
+ toolbarTextView.setTextColor(ThemeUtils.getForegroundColor());
+ }
+
+ rootView.addView(toolbar, 0);
+ return false;
+ }
+ );
+ }
+ }
+
+ // Map to store dependencies: key is the preference key, value is a list of dependent preferences
+ private final Map> dependencyMap = new HashMap<>();
+ // Set to track already added preferences to avoid duplicates
+ private final Set addedPreferences = new HashSet<>();
+ // Map to store preferences grouped by their parent PreferenceGroup
+ private final Map> groupedPreferences = new LinkedHashMap<>();
+
+ @SuppressLint("ResourceType")
+ @Override
+ public void onCreate(Bundle bundle) {
+ super.onCreate(bundle);
+ try {
+ mPreferenceManager = getPreferenceManager();
+ mPreferenceManager.setSharedPreferencesName(Setting.preferences.name);
+ mSharedPreferences = mPreferenceManager.getSharedPreferences();
+ addPreferencesFromResource(getXmlIdentifier("revanced_prefs"));
+
+ // Initialize toolbars and other UI elements
+ setPreferenceScreenToolbar();
+
+ // Initialize ReVanced settings
+ ReVancedSettingsPreference.initializeReVancedSettings();
+ SponsorBlockSettingsPreference.init(getActivity());
+
+ // Import/export
+ setBackupRestorePreference();
+
+ // Store all preferences and their dependencies
+ storeAllPreferences(getPreferenceScreen());
+
+ // Load and set initial preferences states
+ for (Setting> setting : Setting.allLoadedSettings()) {
+ final Preference preference = mPreferenceManager.findPreference(setting.key);
+ if (preference != null && isSDKAbove(26)) {
+ preference.setSingleLineTitle(false);
+ }
+
+ if (preference instanceof SwitchPreference switchPreference) {
+ BooleanSetting boolSetting = (BooleanSetting) setting;
+ switchPreference.setChecked(boolSetting.get());
+ } else if (preference instanceof EditTextPreference editTextPreference) {
+ editTextPreference.setText(setting.get().toString());
+ } else if (preference instanceof ListPreference listPreference) {
+ if (setting.equals(DEFAULT_PLAYBACK_SPEED)) {
+ listPreference.setEntries(CustomPlaybackSpeedPatch.getListEntries());
+ listPreference.setEntryValues(CustomPlaybackSpeedPatch.getListEntryValues());
+ }
+ if (!(preference instanceof app.revanced.extension.youtube.settings.preference.SegmentCategoryListPreference)) {
+ updateListPreferenceSummary(listPreference, setting);
+ }
+ }
+ }
+
+ // Register preference change listener
+ mSharedPreferences.registerOnSharedPreferenceChangeListener(listener);
+
+ originalPreferenceScreen = getPreferenceManager().createPreferenceScreen(getActivity());
+ copyPreferences(getPreferenceScreen(), originalPreferenceScreen);
+ } catch (Exception th) {
+ Logger.printException(() -> "Error during onCreate()", th);
+ }
+ }
+
+ private void copyPreferences(PreferenceScreen source, PreferenceScreen destination) {
+ for (Preference preference : getAllPreferencesBy(source)) {
+ destination.addPreference(preference);
+ }
+ }
+
+ @Override
+ public void onDestroy() {
+ mSharedPreferences.unregisterOnSharedPreferenceChangeListener(listener);
+ super.onDestroy();
+ }
+
+ /**
+ * Recursively stores all preferences and their dependencies grouped by their parent PreferenceGroup.
+ *
+ * @param preferenceGroup The preference group to scan.
+ */
+ private void storeAllPreferences(PreferenceGroup preferenceGroup) {
+ // Check if this is the root PreferenceScreen
+ boolean isRootScreen = preferenceGroup == getPreferenceScreen();
+
+ // Use the special top-level group only for the root PreferenceScreen
+ PreferenceGroup groupKey = isRootScreen
+ ? new PreferenceCategory(preferenceGroup.getContext())
+ : preferenceGroup;
+
+ if (isRootScreen) {
+ groupKey.setTitle(ResourceUtils.getString("revanced_extended_settings_title"));
+ }
+
+ // Initialize a list to hold preferences of the current group
+ List currentGroupPreferences = groupedPreferences.computeIfAbsent(groupKey, k -> new ArrayList<>());
+
+ for (int i = 0; i < preferenceGroup.getPreferenceCount(); i++) {
+ Preference preference = preferenceGroup.getPreference(i);
+
+ // Add preference to the current group if not already added
+ if (!currentGroupPreferences.contains(preference)) {
+ currentGroupPreferences.add(preference);
+ }
+
+ // Store dependencies
+ if (preference.getDependency() != null) {
+ String dependencyKey = preference.getDependency();
+ dependencyMap.computeIfAbsent(dependencyKey, k -> new ArrayList<>()).add(preference);
+ }
+
+ // Recursively handle nested PreferenceGroups
+ if (preference instanceof PreferenceGroup nestedGroup) {
+ storeAllPreferences(nestedGroup);
+ }
+ }
+ }
+
+ /**
+ * Filters preferences based on the search query, displaying grouped results with group titles.
+ *
+ * @param query The search query.
+ */
+ public void filterPreferences(String query) {
+ // If the query is null or empty, reset preferences to their default state
+ if (query == null || query.isEmpty()) {
+ resetPreferences();
+ return;
+ }
+
+ // Convert the query to lowercase for case-insensitive search
+ query = query.toLowerCase();
+
+ // Get the preference screen to modify
+ PreferenceScreen preferenceScreen = getPreferenceScreen();
+ // Remove all current preferences from the screen
+ preferenceScreen.removeAll();
+ // Clear the list of added preferences to start fresh
+ addedPreferences.clear();
+
+ // Create a map to store matched preferences for each group
+ Map> matchedGroupPreferences = new LinkedHashMap<>();
+
+ // Create a set to store all keys that should be included
+ Set keysToInclude = new HashSet<>();
+
+ // First pass: identify all preferences that match the query and their dependencies
+ for (Map.Entry> entry : groupedPreferences.entrySet()) {
+ List preferences = entry.getValue();
+ for (Preference preference : preferences) {
+ if (preferenceMatches(preference, query)) {
+ addPreferenceAndDependencies(preference, keysToInclude);
+ }
+ }
+ }
+
+ // Second pass: add all identified preferences to matchedGroupPreferences
+ for (Map.Entry> entry : groupedPreferences.entrySet()) {
+ PreferenceGroup group = entry.getKey();
+ List preferences = entry.getValue();
+ List matchedPreferences = new ArrayList<>();
+
+ for (Preference preference : preferences) {
+ if (keysToInclude.contains(preference.getKey())) {
+ matchedPreferences.add(preference);
+ }
+ }
+
+ if (!matchedPreferences.isEmpty()) {
+ matchedGroupPreferences.put(group, matchedPreferences);
+ }
+ }
+
+ // Add matched preferences to the screen, maintaining the original order
+ for (Map.Entry> entry : matchedGroupPreferences.entrySet()) {
+ PreferenceGroup group = entry.getKey();
+ List matchedPreferences = entry.getValue();
+
+ // Add the category for this group
+ PreferenceCategory category = new PreferenceCategory(preferenceScreen.getContext());
+ category.setTitle(group.getTitle());
+ preferenceScreen.addPreference(category);
+
+ // Add matched preferences for this group
+ for (Preference preference : matchedPreferences) {
+ if (preference.isSelectable()) {
+ addPreferenceWithDependencies(category, preference);
+ } else {
+ // For non-selectable preferences, just add them directly
+ category.addPreference(preference);
+ }
+ }
+ }
+ }
+
+ /**
+ * Checks if a preference matches the given query.
+ *
+ * @param preference The preference to check.
+ * @param query The search query.
+ * @return True if the preference matches the query, false otherwise.
+ */
+ private boolean preferenceMatches(Preference preference, String query) {
+ // Check if the title contains the query string
+ if (preference.getTitle().toString().toLowerCase().contains(query)) {
+ return true;
+ }
+
+ // Check if the summary contains the query string
+ if (preference.getSummary() != null && preference.getSummary().toString().toLowerCase().contains(query)) {
+ return true;
+ }
+
+ // Additional checks for SwitchPreference
+ if (preference instanceof SwitchPreference switchPreference) {
+ CharSequence summaryOn = switchPreference.getSummaryOn();
+ CharSequence summaryOff = switchPreference.getSummaryOff();
+
+ if ((summaryOn != null && summaryOn.toString().toLowerCase().contains(query)) ||
+ (summaryOff != null && summaryOff.toString().toLowerCase().contains(query))) {
+ return true;
+ }
+ }
+
+ // Additional checks for ListPreference
+ if (preference instanceof ListPreference listPreference) {
+ CharSequence[] entries = listPreference.getEntries();
+ if (entries != null) {
+ for (CharSequence entry : entries) {
+ if (entry.toString().toLowerCase().contains(query)) {
+ return true;
+ }
+ }
+ }
+
+ CharSequence[] entryValues = listPreference.getEntryValues();
+ if (entryValues != null) {
+ for (CharSequence entryValue : entryValues) {
+ if (entryValue.toString().toLowerCase().contains(query)) {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Recursively adds a preference and its dependencies to the set of keys to include.
+ *
+ * @param preference The preference to add.
+ * @param keysToInclude The set of keys to include.
+ */
+ private void addPreferenceAndDependencies(Preference preference, Set keysToInclude) {
+ String key = preference.getKey();
+ if (key != null && !keysToInclude.contains(key)) {
+ keysToInclude.add(key);
+
+ // Add the preference this one depends on
+ String dependencyKey = preference.getDependency();
+ if (dependencyKey != null) {
+ Preference dependency = findPreferenceInAllGroups(dependencyKey);
+ if (dependency != null) {
+ addPreferenceAndDependencies(dependency, keysToInclude);
+ }
+ }
+
+ // Add preferences that depend on this one
+ if (dependencyMap.containsKey(key)) {
+ for (Preference dependentPreference : Objects.requireNonNull(dependencyMap.get(key))) {
+ addPreferenceAndDependencies(dependentPreference, keysToInclude);
+ }
+ }
+ }
+ }
+
+ /**
+ * Recursively adds a preference along with its dependencies
+ * (android:dependency attribute in XML).
+ *
+ * @param preferenceGroup The preference group to add to.
+ * @param preference The preference to add.
+ */
+ private void addPreferenceWithDependencies(PreferenceGroup preferenceGroup, Preference preference) {
+ String key = preference.getKey();
+
+ // Instead of just using preference keys, we combine the category and key to ensure uniqueness
+ if (key != null && !addedPreferences.contains(preferenceGroup.getTitle() + ":" + key)) {
+ // Add dependencies first
+ if (preference.getDependency() != null) {
+ String dependencyKey = preference.getDependency();
+ Preference dependency = findPreferenceInAllGroups(dependencyKey);
+ if (dependency != null) {
+ addPreferenceWithDependencies(preferenceGroup, dependency);
+ } else {
+ return;
+ }
+ }
+
+ // Add the preference using a combination of the category and the key
+ preferenceGroup.addPreference(preference);
+ addedPreferences.add(preferenceGroup.getTitle() + ":" + key); // Track based on both category and key
+
+ // Handle dependent preferences
+ if (dependencyMap.containsKey(key)) {
+ for (Preference dependentPreference : Objects.requireNonNull(dependencyMap.get(key))) {
+ addPreferenceWithDependencies(preferenceGroup, dependentPreference);
+ }
+ }
+ }
+ }
+
+ /**
+ * Finds a preference in all groups based on its key.
+ *
+ * @param key The key of the preference to find.
+ * @return The found preference, or null if not found.
+ */
+ private Preference findPreferenceInAllGroups(String key) {
+ for (List preferences : groupedPreferences.values()) {
+ for (Preference preference : preferences) {
+ if (preference.getKey() != null && preference.getKey().equals(key)) {
+ return preference;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Resets the preference screen to its original state.
+ */
+ private void resetPreferences() {
+ PreferenceScreen preferenceScreen = getPreferenceScreen();
+ preferenceScreen.removeAll();
+ for (Preference preference : getAllPreferencesBy(originalPreferenceScreen))
+ preferenceScreen.addPreference(preference);
+ }
+
+ private List getAllPreferencesBy(PreferenceGroup preferenceGroup) {
+ List preferences = new ArrayList<>();
+ for (int i = 0; i < preferenceGroup.getPreferenceCount(); i++)
+ preferences.add(preferenceGroup.getPreference(i));
+ return preferences;
+ }
+
+ /**
+ * Add Preference to Import/Export settings submenu
+ */
+ private void setBackupRestorePreference() {
+ findPreference("revanced_extended_settings_import").setOnPreferenceClickListener(pref -> {
+ importActivity();
+ return false;
+ });
+
+ findPreference("revanced_extended_settings_export").setOnPreferenceClickListener(pref -> {
+ exportActivity();
+ return false;
+ });
+ }
+
+ /**
+ * Invoke the SAF(Storage Access Framework) to export settings
+ */
+ private void exportActivity() {
+ @SuppressLint("SimpleDateFormat") final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
+
+ final String appName = ExtendedUtils.getApplicationLabel();
+ final String versionName = ExtendedUtils.getVersionName();
+ final String formatDate = dateFormat.format(new Date(System.currentTimeMillis()));
+ final String fileName = String.format("%s_v%s_%s.txt", appName, versionName, formatDate);
+
+ final Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType("text/plain");
+ intent.putExtra(Intent.EXTRA_TITLE, fileName);
+ startActivityForResult(intent, WRITE_REQUEST_CODE);
+ }
+
+ /**
+ * Invoke the SAF(Storage Access Framework) to import settings
+ */
+ private void importActivity() {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.setType(isSDKAbove(29) ? "text/plain" : "*/*");
+ startActivityForResult(intent, READ_REQUEST_CODE);
+ }
+
+ /**
+ * Activity should be done within the lifecycle of PreferenceFragment
+ */
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (requestCode == WRITE_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
+ exportText(data.getData());
+ } else if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK && data != null) {
+ importText(data.getData());
+ }
+ }
+
+ private void exportText(Uri uri) {
+ final Context context = this.getActivity();
+
+ try {
+ @SuppressLint("Recycle")
+ FileWriter jsonFileWriter =
+ new FileWriter(
+ Objects.requireNonNull(context.getApplicationContext()
+ .getContentResolver()
+ .openFileDescriptor(uri, "w"))
+ .getFileDescriptor()
+ );
+ PrintWriter printWriter = new PrintWriter(jsonFileWriter);
+ printWriter.write(Setting.exportToJson(context));
+ printWriter.close();
+ jsonFileWriter.close();
+
+ showToastShort(str("revanced_extended_settings_export_success"));
+ } catch (IOException e) {
+ showToastShort(str("revanced_extended_settings_export_failed"));
+ }
+ }
+
+ private void importText(Uri uri) {
+ final Context context = this.getActivity();
+ StringBuilder sb = new StringBuilder();
+ String line;
+
+ try {
+ settingImportInProgress = true;
+
+ @SuppressLint("Recycle")
+ FileReader fileReader =
+ new FileReader(
+ Objects.requireNonNull(context.getApplicationContext()
+ .getContentResolver()
+ .openFileDescriptor(uri, "r"))
+ .getFileDescriptor()
+ );
+ BufferedReader bufferedReader = new BufferedReader(fileReader);
+ while ((line = bufferedReader.readLine()) != null) {
+ sb.append(line).append("\n");
+ }
+ bufferedReader.close();
+ fileReader.close();
+
+ final boolean restartNeeded = Setting.importFromJSON(sb.toString(), true);
+ if (restartNeeded) {
+ showRestartDialog(getActivity());
+ }
+ } catch (IOException e) {
+ showToastShort(str("revanced_extended_settings_import_failed"));
+ throw new RuntimeException(e);
+ } finally {
+ settingImportInProgress = false;
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedSettingsPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedSettingsPreference.java
new file mode 100644
index 0000000000..e902d2ab8e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ReVancedSettingsPreference.java
@@ -0,0 +1,277 @@
+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.patches.general.MiniplayerPatch.MiniplayerType.MODERN_1;
+import static app.revanced.extension.youtube.patches.general.MiniplayerPatch.MiniplayerType.MODERN_3;
+import static app.revanced.extension.youtube.utils.ExtendedUtils.isSpoofingToLessThan;
+
+import android.preference.Preference;
+import android.preference.SwitchPreference;
+
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.youtube.patches.general.LayoutSwitchPatch;
+import app.revanced.extension.youtube.patches.general.MiniplayerPatch;
+import app.revanced.extension.youtube.patches.utils.PatchStatus;
+import app.revanced.extension.youtube.patches.utils.ReturnYouTubeDislikePatch;
+import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.utils.ExtendedUtils;
+
+@SuppressWarnings("deprecation")
+public class ReVancedSettingsPreference extends ReVancedPreferenceFragment {
+
+ private static void enableDisablePreferences() {
+ for (Setting> setting : Setting.allLoadedSettings()) {
+ final Preference preference = mPreferenceManager.findPreference(setting.key);
+ if (preference != null) {
+ preference.setEnabled(setting.isAvailable());
+ }
+ }
+ }
+
+ private static void enableDisablePreferences(final boolean isAvailable, final Setting>... unavailableEnum) {
+ if (!isAvailable) {
+ return;
+ }
+ for (Setting> setting : unavailableEnum) {
+ final Preference preference = mPreferenceManager.findPreference(setting.key);
+ if (preference != null) {
+ preference.setEnabled(false);
+ }
+ }
+ }
+
+ public static void initializeReVancedSettings() {
+ enableDisablePreferences();
+
+ AmbientModePreferenceLinks();
+ ChangeHeaderPreferenceLinks();
+ ExternalDownloaderPreferenceLinks();
+ FullScreenPanelPreferenceLinks();
+ LayoutOverrideLinks();
+ MiniPlayerPreferenceLinks();
+ NavigationPreferenceLinks();
+ RYDPreferenceLinks();
+ SeekBarPreferenceLinks();
+ SpeedOverlayPreferenceLinks();
+ QuickActionsPreferenceLinks();
+ TabletLayoutLinks();
+ WhitelistPreferenceLinks();
+ }
+
+ /**
+ * Enable/Disable Preference related to Ambient Mode
+ */
+ private static void AmbientModePreferenceLinks() {
+ enableDisablePreferences(
+ Settings.DISABLE_AMBIENT_MODE.get(),
+ Settings.BYPASS_AMBIENT_MODE_RESTRICTIONS,
+ Settings.DISABLE_AMBIENT_MODE_IN_FULLSCREEN
+ );
+ }
+
+ /**
+ * Enable/Disable Preference related to Change header
+ */
+ private static void ChangeHeaderPreferenceLinks() {
+ enableDisablePreferences(
+ PatchStatus.MinimalHeader(),
+ Settings.CHANGE_YOUTUBE_HEADER
+ );
+ }
+
+ /**
+ * 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 Layout Override Preference
+ */
+ private static void LayoutOverrideLinks() {
+ enableDisablePreferences(
+ ExtendedUtils.isTablet(),
+ Settings.FORCE_FULLSCREEN
+ );
+ }
+
+ /**
+ * Enable/Disable Preferences not working in tablet layout
+ */
+ private static void TabletLayoutLinks() {
+ final boolean isTablet = ExtendedUtils.isTablet() &&
+ !LayoutSwitchPatch.phoneLayoutEnabled();
+
+ enableDisablePreferences(
+ isTablet,
+ Settings.DISABLE_ENGAGEMENT_PANEL,
+ Settings.HIDE_COMMUNITY_POSTS_HOME_RELATED_VIDEOS,
+ Settings.HIDE_COMMUNITY_POSTS_SUBSCRIPTIONS,
+ Settings.HIDE_MIX_PLAYLISTS,
+ Settings.HIDE_RELATED_VIDEO_OVERLAY,
+ Settings.SHOW_VIDEO_TITLE_SECTION
+ );
+ }
+
+ /**
+ * Enable/Disable Preference related to Fullscreen Panel
+ */
+ private static void FullScreenPanelPreferenceLinks() {
+ enableDisablePreferences(
+ Settings.DISABLE_ENGAGEMENT_PANEL.get(),
+ Settings.HIDE_RELATED_VIDEO_OVERLAY,
+ Settings.HIDE_QUICK_ACTIONS,
+ Settings.HIDE_QUICK_ACTIONS_COMMENT_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_DISLIKE_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_LIKE_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_LIVE_CHAT_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_MORE_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_OPEN_MIX_PLAYLIST_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_OPEN_PLAYLIST_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_SAVE_TO_PLAYLIST_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_SHARE_BUTTON
+ );
+
+ enableDisablePreferences(
+ Settings.DISABLE_LANDSCAPE_MODE.get(),
+ Settings.FORCE_FULLSCREEN
+ );
+
+ enableDisablePreferences(
+ Settings.FORCE_FULLSCREEN.get(),
+ Settings.DISABLE_LANDSCAPE_MODE
+ );
+
+ }
+
+ /**
+ * Enable/Disable Preference related to Hide Quick Actions
+ */
+ private static void QuickActionsPreferenceLinks() {
+ final boolean isEnabled =
+ Settings.DISABLE_ENGAGEMENT_PANEL.get() || Settings.HIDE_QUICK_ACTIONS.get();
+
+ enableDisablePreferences(
+ isEnabled,
+ Settings.HIDE_QUICK_ACTIONS_COMMENT_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_DISLIKE_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_LIKE_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_LIVE_CHAT_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_MORE_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_OPEN_MIX_PLAYLIST_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_OPEN_PLAYLIST_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_SAVE_TO_PLAYLIST_BUTTON,
+ Settings.HIDE_QUICK_ACTIONS_SHARE_BUTTON
+ );
+ }
+
+ /**
+ * Enable/Disable Preference related to Miniplayer settings
+ */
+ private static void MiniPlayerPreferenceLinks() {
+ final MiniplayerPatch.MiniplayerType CURRENT_TYPE = Settings.MINIPLAYER_TYPE.get();
+ final boolean available =
+ (CURRENT_TYPE == MODERN_1 || CURRENT_TYPE == MODERN_3) &&
+ !Settings.MINIPLAYER_DOUBLE_TAP_ACTION.get() &&
+ !Settings.MINIPLAYER_DRAG_AND_DROP.get();
+
+ enableDisablePreferences(
+ !available,
+ Settings.MINIPLAYER_HIDE_EXPAND_CLOSE
+ );
+ }
+
+ /**
+ * Enable/Disable Preference related to Navigation settings
+ */
+ private static void NavigationPreferenceLinks() {
+ enableDisablePreferences(
+ Settings.SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON.get(),
+ Settings.HIDE_NAVIGATION_CREATE_BUTTON
+ );
+ enableDisablePreferences(
+ !Settings.SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON.get(),
+ Settings.HIDE_NAVIGATION_NOTIFICATIONS_BUTTON,
+ Settings.REPLACE_TOOLBAR_CREATE_BUTTON,
+ Settings.REPLACE_TOOLBAR_CREATE_BUTTON_TYPE
+ );
+ enableDisablePreferences(
+ !isSDKAbove(31),
+ Settings.ENABLE_TRANSLUCENT_NAVIGATION_BAR
+ );
+ }
+
+ /**
+ * Enable/Disable Preference related to RYD settings
+ */
+ private static void RYDPreferenceLinks() {
+ if (!(mPreferenceManager.findPreference(Settings.RYD_ENABLED.key) instanceof SwitchPreference enabledPreference)) {
+ return;
+ }
+ if (!(mPreferenceManager.findPreference(Settings.RYD_SHORTS.key) instanceof SwitchPreference shortsPreference)) {
+ return;
+ }
+ if (!(mPreferenceManager.findPreference(Settings.RYD_DISLIKE_PERCENTAGE.key) instanceof SwitchPreference percentagePreference)) {
+ return;
+ }
+ if (!(mPreferenceManager.findPreference(Settings.RYD_COMPACT_LAYOUT.key) instanceof SwitchPreference compactLayoutPreference)) {
+ return;
+ }
+ final Preference.OnPreferenceChangeListener clearAllUICaches = (pref, newValue) -> {
+ ReturnYouTubeDislike.clearAllUICaches();
+
+ return true;
+ };
+ enabledPreference.setOnPreferenceChangeListener((pref, newValue) -> {
+ ReturnYouTubeDislikePatch.onRYDStatusChange();
+
+ return true;
+ });
+ String shortsSummary = ReturnYouTubeDislikePatch.IS_SPOOFING_TO_NON_LITHO_SHORTS_PLAYER
+ ? str("revanced_ryd_shorts_summary_on")
+ : str("revanced_ryd_shorts_summary_on_disclaimer");
+ shortsPreference.setSummaryOn(shortsSummary);
+ percentagePreference.setOnPreferenceChangeListener(clearAllUICaches);
+ compactLayoutPreference.setOnPreferenceChangeListener(clearAllUICaches);
+ }
+
+ /**
+ * Enable/Disable Preference related to Seek bar settings
+ */
+ private static void SeekBarPreferenceLinks() {
+ enableDisablePreferences(
+ Settings.RESTORE_OLD_SEEKBAR_THUMBNAILS.get(),
+ Settings.ENABLE_SEEKBAR_THUMBNAILS_HIGH_QUALITY
+ );
+ }
+
+ /**
+ * Enable/Disable Preference related to Speed overlay settings
+ */
+ private static void SpeedOverlayPreferenceLinks() {
+ enableDisablePreferences(
+ Settings.DISABLE_SPEED_OVERLAY.get(),
+ Settings.SPEED_OVERLAY_VALUE
+ );
+ }
+
+ private static void WhitelistPreferenceLinks() {
+ final boolean enabled = PatchStatus.RememberPlaybackSpeed() || PatchStatus.SponsorBlock();
+ final String[] whitelistKey = {Settings.OVERLAY_BUTTON_WHITELIST.key, "revanced_whitelist_settings"};
+
+ for (String key : whitelistKey) {
+ final Preference preference = mPreferenceManager.findPreference(key);
+ if (preference != null) {
+ preference.setEnabled(enabled);
+ }
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SegmentCategoryListPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SegmentCategoryListPreference.java
new file mode 100644
index 0000000000..b94ee31357
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SegmentCategoryListPreference.java
@@ -0,0 +1,180 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.graphics.Color;
+import android.preference.ListPreference;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TableLayout;
+import android.widget.TableRow;
+import android.widget.TextView;
+
+import java.util.Objects;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour;
+import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class SegmentCategoryListPreference extends ListPreference {
+ private SegmentCategory mCategory;
+ private EditText mEditText;
+ private int mClickedDialogEntryIndex;
+
+ private void init() {
+ final SegmentCategory segmentCategory = SegmentCategory.byCategoryKey(getKey());
+ final boolean isHighlightCategory = segmentCategory == SegmentCategory.HIGHLIGHT;
+ mCategory = Objects.requireNonNull(segmentCategory);
+ // Edit: Using preferences to sync together multiple pieces
+ // of code together is messy and should be rethought.
+ setKey(segmentCategory.behaviorSetting.key);
+ setDefaultValue(segmentCategory.behaviorSetting.defaultValue);
+
+ setEntries(isHighlightCategory
+ ? CategoryBehaviour.getBehaviorDescriptionsWithoutSkipOnce()
+ : CategoryBehaviour.getBehaviorDescriptions());
+ setEntryValues(isHighlightCategory
+ ? CategoryBehaviour.getBehaviorKeyValuesWithoutSkipOnce()
+ : CategoryBehaviour.getBehaviorKeyValues());
+ updateTitle();
+ }
+
+ public SegmentCategoryListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+
+ public SegmentCategoryListPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ public SegmentCategoryListPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public SegmentCategoryListPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ try {
+ Utils.setEditTextDialogTheme(builder);
+ super.onPrepareDialogBuilder(builder);
+
+ Context context = builder.getContext();
+ TableLayout table = new TableLayout(context);
+ table.setOrientation(LinearLayout.HORIZONTAL);
+ table.setPadding(70, 0, 150, 0);
+
+ TableRow row = new TableRow(context);
+
+ TextView colorTextLabel = new TextView(context);
+ colorTextLabel.setText(str("revanced_sb_color_dot_label"));
+ row.addView(colorTextLabel);
+
+ TextView colorDotView = new TextView(context);
+ colorDotView.setText(mCategory.getCategoryColorDot());
+ colorDotView.setPadding(30, 0, 30, 0);
+ row.addView(colorDotView);
+
+ mEditText = new EditText(context);
+ mEditText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS);
+ mEditText.setText(mCategory.colorString());
+ mEditText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ try {
+ String colorString = s.toString();
+ if (!colorString.startsWith("#")) {
+ s.insert(0, "#"); // recursively calls back into this method
+ return;
+ }
+ if (colorString.length() > 7) {
+ s.delete(7, colorString.length());
+ return;
+ }
+ final int color = Color.parseColor(colorString);
+ colorDotView.setText(SegmentCategory.getCategoryColorDot(color));
+ } catch (IllegalArgumentException ex) {
+ // ignore
+ }
+ }
+ });
+ mEditText.setLayoutParams(new TableRow.LayoutParams(0, TableRow.LayoutParams.WRAP_CONTENT, 1f));
+ row.addView(mEditText);
+
+ table.addView(row);
+ builder.setView(table);
+ builder.setTitle(mCategory.title.toString());
+
+ builder.setPositiveButton(android.R.string.ok, (dialog, which) -> onClick(dialog, DialogInterface.BUTTON_POSITIVE));
+ builder.setNeutralButton(str("revanced_sb_reset_color"), (dialog, which) -> {
+ try {
+ mCategory.resetColor();
+ updateTitle();
+ Utils.showToastShort(str("revanced_sb_color_reset"));
+ } catch (Exception ex) {
+ Logger.printException(() -> "setNeutralButton failure", ex);
+ }
+ });
+ builder.setNegativeButton(android.R.string.cancel, null);
+ mClickedDialogEntryIndex = findIndexOfValue(getValue());
+ builder.setSingleChoiceItems(getEntries(), mClickedDialogEntryIndex, (dialog, which) -> mClickedDialogEntryIndex = which);
+ } catch (Exception ex) {
+ Logger.printException(() -> "onPrepareDialogBuilder failure", ex);
+ }
+ }
+
+ @Override
+ protected void onDialogClosed(boolean positiveResult) {
+ try {
+ if (positiveResult && mClickedDialogEntryIndex >= 0 && getEntryValues() != null) {
+ String value = getEntryValues()[mClickedDialogEntryIndex].toString();
+ if (callChangeListener(value)) {
+ setValue(value);
+ mCategory.setBehaviour(Objects.requireNonNull(CategoryBehaviour.byReVancedKeyValue(value)));
+ SegmentCategory.updateEnabledCategories();
+ }
+ String colorString = mEditText.getText().toString();
+ try {
+ if (!colorString.equals(mCategory.colorString())) {
+ mCategory.setColor(colorString);
+ Utils.showToastShort(str("revanced_sb_color_changed"));
+ }
+ } catch (IllegalArgumentException ex) {
+ Utils.showToastShort(str("revanced_sb_color_invalid"));
+ }
+ updateTitle();
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onDialogClosed failure", ex);
+ }
+ }
+
+ private void updateTitle() {
+ setTitle(mCategory.getTitleWithColorDot());
+ setEnabled(Settings.SB_ENABLED.get());
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockImportExportPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockImportExportPreference.java
new file mode 100644
index 0000000000..1d53842dd6
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockImportExportPreference.java
@@ -0,0 +1,108 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.annotation.TargetApi;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.preference.EditTextPreference;
+import android.preference.Preference;
+import android.text.InputType;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.widget.EditText;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class SponsorBlockImportExportPreference extends EditTextPreference implements Preference.OnPreferenceClickListener {
+
+ private String existingSettings;
+
+ @TargetApi(26)
+ private void init() {
+ setSelectable(true);
+
+ EditText editText = getEditText();
+ editText.setTextIsSelectable(true);
+ editText.setAutofillHints((String) null);
+ editText.setInputType(editText.getInputType()
+ | InputType.TYPE_CLASS_TEXT
+ | InputType.TYPE_TEXT_FLAG_MULTI_LINE
+ | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
+ editText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 8); // Use a smaller font to reduce text wrap.
+
+ // If the user has a private user id, then include a subtext that mentions not to share it.
+ String importExportSummary = SponsorBlockSettings.userHasSBPrivateId()
+ ? str("revanced_sb_settings_ie_sum_warning")
+ : str("revanced_sb_settings_ie_sum");
+ setSummary(importExportSummary);
+
+ setOnPreferenceClickListener(this);
+ }
+
+ public SponsorBlockImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+
+ public SponsorBlockImportExportPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ public SponsorBlockImportExportPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public SponsorBlockImportExportPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ try {
+ // Must set text before preparing dialog, otherwise text is non selectable if this preference is later reopened.
+ existingSettings = SponsorBlockSettings.exportDesktopSettings();
+ getEditText().setText(existingSettings);
+ } catch (Exception ex) {
+ Logger.printException(() -> "showDialog failure", ex);
+ }
+ return true;
+ }
+
+ @Override
+ protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
+ try {
+ Utils.setEditTextDialogTheme(builder);
+ super.onPrepareDialogBuilder(builder);
+ // Show the user the settings in JSON format.
+ builder.setTitle(getTitle());
+ builder.setNeutralButton(str("revanced_sb_settings_copy"), (dialog, which) ->
+ Utils.setClipboard(getEditText().getText().toString(), str("revanced_sb_share_copy_settings_success")))
+ .setPositiveButton(android.R.string.ok, (dialog, which) ->
+ importSettings(getEditText().getText().toString()));
+ } catch (Exception ex) {
+ Logger.printException(() -> "onPrepareDialogBuilder failure", ex);
+ }
+ }
+
+ private void importSettings(String replacementSettings) {
+ try {
+ if (replacementSettings.equals(existingSettings)) {
+ return;
+ }
+ SponsorBlockSettings.importDesktopSettings(replacementSettings);
+ SponsorBlockSettingsPreference.updateSegmentCategories();
+ SponsorBlockSettingsPreference.fetchAndDisplayStats();
+ SponsorBlockSettingsPreference.updateUI();
+ } catch (Exception ex) {
+ Logger.printException(() -> "importSettings failure", ex);
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockSettingsPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockSettingsPreference.java
new file mode 100644
index 0000000000..6a8a4a0b55
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SponsorBlockSettingsPreference.java
@@ -0,0 +1,432 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static android.text.Html.fromHtml;
+import static app.revanced.extension.shared.utils.ResourceUtils.getLayoutIdentifier;
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.annotation.TargetApi;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.net.Uri;
+import android.preference.Preference;
+import android.preference.PreferenceCategory;
+import android.preference.PreferenceScreen;
+import android.preference.SwitchPreference;
+import android.text.InputType;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TableLayout;
+import android.widget.TableRow;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Objects;
+
+import app.revanced.extension.shared.settings.BooleanSetting;
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.settings.preference.ResettableEditTextPreference;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.utils.PatchStatus;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.sponsorblock.SegmentPlaybackController;
+import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings;
+import app.revanced.extension.youtube.sponsorblock.SponsorBlockUtils;
+import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory;
+import app.revanced.extension.youtube.sponsorblock.objects.UserStats;
+import app.revanced.extension.youtube.sponsorblock.requests.SBRequester;
+import app.revanced.extension.youtube.sponsorblock.ui.SponsorBlockViewController;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class SponsorBlockSettingsPreference extends ReVancedPreferenceFragment {
+
+ private static PreferenceCategory statsCategory;
+
+ private static final int preferencesCategoryLayout = getLayoutIdentifier("revanced_settings_preferences_category");
+
+ private static final Preference.OnPreferenceChangeListener updateUI = (pref, newValue) -> {
+ updateUI();
+
+ return true;
+ };
+
+ @NonNull
+ private static SwitchPreference findSwitchPreference(BooleanSetting setting) {
+ final String key = setting.key;
+ if (mPreferenceManager.findPreference(key) instanceof SwitchPreference switchPreference) {
+ switchPreference.setOnPreferenceChangeListener(updateUI);
+ return switchPreference;
+ } else {
+ throw new IllegalStateException("SwitchPreference is null: " + key);
+ }
+ }
+
+ @NonNull
+ private static ResettableEditTextPreference findResettableEditTextPreference(Setting> setting) {
+ final String key = setting.key;
+ if (mPreferenceManager.findPreference(key) instanceof ResettableEditTextPreference switchPreference) {
+ switchPreference.setOnPreferenceChangeListener(updateUI);
+ return switchPreference;
+ } else {
+ throw new IllegalStateException("ResettableEditTextPreference is null: " + key);
+ }
+ }
+
+ public static void updateUI() {
+ if (!Settings.SB_ENABLED.get()) {
+ SponsorBlockViewController.hideAll();
+ SegmentPlaybackController.clearData();
+ } else if (!Settings.SB_CREATE_NEW_SEGMENT.get()) {
+ SponsorBlockViewController.hideNewSegmentLayout();
+ }
+ }
+
+ @TargetApi(26)
+ public static void init(Activity mActivity) {
+ if (!PatchStatus.SponsorBlock()) {
+ return;
+ }
+
+ final SwitchPreference sbEnabled = findSwitchPreference(Settings.SB_ENABLED);
+ sbEnabled.setOnPreferenceClickListener(preference -> {
+ updateUI();
+ fetchAndDisplayStats();
+ updateSegmentCategories();
+ return false;
+ });
+
+ if (!(sbEnabled.getParent() instanceof PreferenceScreen mPreferenceScreen)) {
+ return;
+ }
+
+ final SwitchPreference votingEnabled = findSwitchPreference(Settings.SB_VOTING_BUTTON);
+ final SwitchPreference compactSkipButton = findSwitchPreference(Settings.SB_COMPACT_SKIP_BUTTON);
+ final SwitchPreference autoHideSkipSegmentButton = findSwitchPreference(Settings.SB_AUTO_HIDE_SKIP_BUTTON);
+ final SwitchPreference showSkipToast = findSwitchPreference(Settings.SB_TOAST_ON_SKIP);
+ showSkipToast.setOnPreferenceClickListener(preference -> {
+ Utils.showToastShort(str("revanced_sb_skipped_sponsor"));
+ return false;
+ });
+
+ final SwitchPreference showTimeWithoutSegments = findSwitchPreference(Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS);
+
+ final SwitchPreference addNewSegment = findSwitchPreference(Settings.SB_CREATE_NEW_SEGMENT);
+ addNewSegment.setOnPreferenceChangeListener((preference, newValue) -> {
+ if ((Boolean) newValue && !Settings.SB_SEEN_GUIDELINES.get()) {
+ Context context = preference.getContext();
+ new AlertDialog.Builder(context)
+ .setTitle(str("revanced_sb_guidelines_popup_title"))
+ .setMessage(str("revanced_sb_guidelines_popup_content"))
+ .setNegativeButton(str("revanced_sb_guidelines_popup_already_read"), null)
+ .setPositiveButton(str("revanced_sb_guidelines_popup_open"), (dialogInterface, i) -> openGuidelines(context))
+ .setOnDismissListener(dialog -> Settings.SB_SEEN_GUIDELINES.save(true))
+ .setCancelable(false)
+ .show();
+ }
+ updateUI();
+ return true;
+ });
+
+ final ResettableEditTextPreference newSegmentStep = findResettableEditTextPreference(Settings.SB_CREATE_NEW_SEGMENT_STEP);
+ newSegmentStep.setOnPreferenceChangeListener((preference, newValue) -> {
+ try {
+ final int newAdjustmentValue = Integer.parseInt(newValue.toString());
+ if (newAdjustmentValue != 0) {
+ Settings.SB_CREATE_NEW_SEGMENT_STEP.save(newAdjustmentValue);
+ return true;
+ }
+ } catch (NumberFormatException ex) {
+ Logger.printInfo(() -> "Invalid new segment step", ex);
+ }
+
+ Utils.showToastLong(str("revanced_sb_general_adjusting_invalid"));
+ updateUI();
+ return false;
+ });
+ final Preference guidelinePreferences = Objects.requireNonNull(mPreferenceManager.findPreference("revanced_sb_guidelines_preference"));
+ guidelinePreferences.setDependency(Settings.SB_ENABLED.key);
+ guidelinePreferences.setOnPreferenceClickListener(preference -> {
+ openGuidelines(preference.getContext());
+ return true;
+ });
+
+ final SwitchPreference toastOnConnectionError = findSwitchPreference(Settings.SB_TOAST_ON_CONNECTION_ERROR);
+ final SwitchPreference trackSkips = findSwitchPreference(Settings.SB_TRACK_SKIP_COUNT);
+ final ResettableEditTextPreference minSegmentDuration = findResettableEditTextPreference(Settings.SB_SEGMENT_MIN_DURATION);
+ minSegmentDuration.setOnPreferenceChangeListener((preference, newValue) -> {
+ try {
+ Float minTimeDuration = Float.valueOf(newValue.toString());
+ Settings.SB_SEGMENT_MIN_DURATION.save(minTimeDuration);
+ return true;
+ } catch (NumberFormatException ex) {
+ Logger.printInfo(() -> "Invalid minimum segment duration", ex);
+ }
+
+ Utils.showToastLong(str("revanced_sb_general_min_duration_invalid"));
+ updateUI();
+ return false;
+ });
+ final ResettableEditTextPreference privateUserId = findResettableEditTextPreference(Settings.SB_PRIVATE_USER_ID);
+ privateUserId.setOnPreferenceChangeListener((preference, newValue) -> {
+ String newUUID = newValue.toString();
+ if (!SponsorBlockSettings.isValidSBUserId(newUUID)) {
+ Utils.showToastLong(str("revanced_sb_general_uuid_invalid"));
+ return false;
+ }
+
+ Settings.SB_PRIVATE_USER_ID.save(newUUID);
+ try {
+ updateUI();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ fetchAndDisplayStats();
+ return true;
+ });
+ final Preference apiUrl = mPreferenceManager.findPreference(Settings.SB_API_URL.key);
+ if (apiUrl != null) {
+ apiUrl.setOnPreferenceClickListener(preference -> {
+ Context context = preference.getContext();
+
+ TableLayout table = new TableLayout(context);
+ table.setOrientation(LinearLayout.HORIZONTAL);
+ table.setPadding(15, 0, 15, 0);
+
+ TableRow row = new TableRow(context);
+
+ EditText editText = new EditText(context);
+ editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
+ editText.setText(Settings.SB_API_URL.get());
+ editText.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT, 1f));
+ row.addView(editText);
+ table.addView(row);
+
+ DialogInterface.OnClickListener urlChangeListener = (dialog, buttonPressed) -> {
+ if (buttonPressed == DialogInterface.BUTTON_NEUTRAL) {
+ Settings.SB_API_URL.resetToDefault();
+ Utils.showToastLong(str("revanced_sb_api_url_reset"));
+ } else if (buttonPressed == DialogInterface.BUTTON_POSITIVE) {
+ String serverAddress = editText.getText().toString();
+ if (!SponsorBlockSettings.isValidSBServerAddress(serverAddress)) {
+ Utils.showToastLong(str("revanced_sb_api_url_invalid"));
+ } else if (!serverAddress.equals(Settings.SB_API_URL.get())) {
+ Settings.SB_API_URL.save(serverAddress);
+ Utils.showToastLong(str("revanced_sb_api_url_changed"));
+ }
+ }
+ };
+ Utils.getEditTextDialogBuilder(context)
+ .setView(table)
+ .setTitle(apiUrl.getTitle())
+ .setNegativeButton(android.R.string.cancel, null)
+ .setNeutralButton(str("revanced_sb_reset"), urlChangeListener)
+ .setPositiveButton(android.R.string.ok, urlChangeListener)
+ .show();
+ return true;
+ });
+ }
+
+ statsCategory = new PreferenceCategory(mActivity);
+ statsCategory.setLayoutResource(preferencesCategoryLayout);
+ statsCategory.setTitle(str("revanced_sb_stats"));
+ mPreferenceScreen.addPreference(statsCategory);
+ fetchAndDisplayStats();
+
+ final PreferenceCategory aboutCategory = new PreferenceCategory(mActivity);
+ aboutCategory.setLayoutResource(preferencesCategoryLayout);
+ aboutCategory.setTitle(str("revanced_sb_about"));
+ mPreferenceScreen.addPreference(aboutCategory);
+
+ Preference aboutPreference = new Preference(mActivity);
+ aboutCategory.addPreference(aboutPreference);
+ aboutPreference.setTitle(str("revanced_sb_about_api"));
+ aboutPreference.setSummary(str("revanced_sb_about_api_sum"));
+ aboutPreference.setOnPreferenceClickListener(preference -> {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(Uri.parse("https://sponsor.ajay.app"));
+ preference.getContext().startActivity(i);
+ return false;
+ });
+
+ updateUI();
+ }
+
+ public static void updateSegmentCategories() {
+ try {
+ for (SegmentCategory category : SegmentCategory.categoriesWithoutUnsubmitted()) {
+ final String key = category.keyValue;
+ if (mPreferenceManager.findPreference(key) instanceof SegmentCategoryListPreference segmentCategoryListPreference) {
+ segmentCategoryListPreference.setTitle(category.getTitleWithColorDot());
+ segmentCategoryListPreference.setEnabled(Settings.SB_ENABLED.get());
+ }
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "updateSegmentCategories failure", ex);
+ }
+ }
+
+ private static void openGuidelines(Context context) {
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse("https://wiki.sponsor.ajay.app/w/Guidelines"));
+ context.startActivity(intent);
+ }
+
+ public static void fetchAndDisplayStats() {
+ try {
+ if (statsCategory == null) {
+ return;
+ }
+ statsCategory.removeAll();
+ if (!SponsorBlockSettings.userHasSBPrivateId()) {
+ // User has never voted or created any segments. No stats to show.
+ addLocalUserStats();
+ return;
+ }
+
+ Context context = statsCategory.getContext();
+
+ Preference loadingPlaceholderPreference = new Preference(context);
+ loadingPlaceholderPreference.setEnabled(false);
+ statsCategory.addPreference(loadingPlaceholderPreference);
+ if (Settings.SB_ENABLED.get()) {
+ loadingPlaceholderPreference.setTitle(str("revanced_sb_stats_loading"));
+ Utils.runOnBackgroundThread(() -> {
+ UserStats stats = SBRequester.retrieveUserStats();
+ Utils.runOnMainThread(() -> { // get back on main thread to modify UI elements
+ addUserStats(loadingPlaceholderPreference, stats);
+ addLocalUserStats();
+ });
+ });
+ } else {
+ loadingPlaceholderPreference.setTitle(str("revanced_sb_stats_sb_disabled"));
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "fetchAndDisplayStats failure", ex);
+ }
+ }
+
+ private static void addUserStats(@NonNull Preference loadingPlaceholder, @Nullable UserStats stats) {
+ Utils.verifyOnMainThread();
+ try {
+ if (stats == null) {
+ loadingPlaceholder.setTitle(str("revanced_sb_stats_connection_failure"));
+ return;
+ }
+ statsCategory.removeAll();
+ Context context = statsCategory.getContext();
+
+ if (stats.totalSegmentCountIncludingIgnored > 0) {
+ // If user has not created any segments, there's no reason to set a username.
+ ResettableEditTextPreference preference = new ResettableEditTextPreference(context);
+ statsCategory.addPreference(preference);
+ String userName = stats.userName;
+ preference.setTitle(fromHtml(str("revanced_sb_stats_username", userName)));
+ preference.setSummary(str("revanced_sb_stats_username_change"));
+ preference.setText(userName);
+ preference.setOnPreferenceChangeListener((preference1, value) -> {
+ Utils.runOnBackgroundThread(() -> {
+ String newUserName = (String) value;
+ String errorMessage = SBRequester.setUsername(newUserName);
+ Utils.runOnMainThread(() -> {
+ if (errorMessage == null) {
+ preference.setTitle(fromHtml(str("revanced_sb_stats_username", newUserName)));
+ preference.setText(newUserName);
+ Utils.showToastLong(str("revanced_sb_stats_username_changed"));
+ } else {
+ preference.setText(userName); // revert to previous
+ Utils.showToastLong(errorMessage);
+ }
+ });
+ });
+ return true;
+ });
+ }
+
+ {
+ // number of segment submissions (does not include ignored segments)
+ Preference preference = new Preference(context);
+ statsCategory.addPreference(preference);
+ String formatted = SponsorBlockUtils.getNumberOfSkipsString(stats.segmentCount);
+ preference.setTitle(fromHtml(str("revanced_sb_stats_submissions", formatted)));
+ preference.setSummary(str("revanced_sb_stats_submissions_sum"));
+ if (stats.totalSegmentCountIncludingIgnored == 0) {
+ preference.setSelectable(false);
+ } else {
+ preference.setOnPreferenceClickListener(preference1 -> {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(Uri.parse("https://sb.ltn.fi/userid/" + stats.publicUserId));
+ preference1.getContext().startActivity(i);
+ return true;
+ });
+ }
+ }
+
+ {
+ // "user reputation". Usually not useful, since it appears most users have zero reputation.
+ // But if there is a reputation, then show it here
+ Preference preference = new Preference(context);
+ preference.setTitle(fromHtml(str("revanced_sb_stats_reputation", stats.reputation)));
+ preference.setSelectable(false);
+ if (stats.reputation != 0) {
+ statsCategory.addPreference(preference);
+ }
+ }
+
+ {
+ // time saved for other users
+ Preference preference = new Preference(context);
+ statsCategory.addPreference(preference);
+
+ String stats_saved;
+ String stats_saved_sum;
+ if (stats.totalSegmentCountIncludingIgnored == 0) {
+ stats_saved = str("revanced_sb_stats_saved_zero");
+ stats_saved_sum = str("revanced_sb_stats_saved_sum_zero");
+ } else {
+ stats_saved = str("revanced_sb_stats_saved",
+ SponsorBlockUtils.getNumberOfSkipsString(stats.viewCount));
+ stats_saved_sum = str("revanced_sb_stats_saved_sum", SponsorBlockUtils.getTimeSavedString((long) (60 * stats.minutesSaved)));
+ }
+ preference.setTitle(fromHtml(stats_saved));
+ preference.setSummary(fromHtml(stats_saved_sum));
+ preference.setOnPreferenceClickListener(preference1 -> {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(Uri.parse("https://sponsor.ajay.app/stats/"));
+ preference1.getContext().startActivity(i);
+ return false;
+ });
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "addUserStats failure", ex);
+ }
+ }
+
+ private static void addLocalUserStats() {
+ // time the user saved by using SB
+ Preference preference = new Preference(statsCategory.getContext());
+ statsCategory.addPreference(preference);
+
+ Runnable updateStatsSelfSaved = () -> {
+ String formatted = SponsorBlockUtils.getNumberOfSkipsString(Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.get());
+ preference.setTitle(fromHtml(str("revanced_sb_stats_self_saved", formatted)));
+ String formattedSaved = SponsorBlockUtils.getTimeSavedString(Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.get() / 1000);
+ preference.setSummary(fromHtml(str("revanced_sb_stats_self_saved_sum", formattedSaved)));
+ };
+ updateStatsSelfSaved.run();
+ preference.setOnPreferenceClickListener(preference1 -> {
+ new AlertDialog.Builder(preference1.getContext())
+ .setTitle(str("revanced_sb_stats_self_saved_reset_title"))
+ .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> {
+ Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.resetToDefault();
+ Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.resetToDefault();
+ updateStatsSelfSaved.run();
+ })
+ .setNegativeButton(android.R.string.no, null).show();
+ return true;
+ });
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SpoofStreamingDataSideEffectsPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SpoofStreamingDataSideEffectsPreference.java
new file mode 100644
index 0000000000..3ada4f0ade
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/SpoofStreamingDataSideEffectsPreference.java
@@ -0,0 +1,78 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.Preference;
+import android.preference.PreferenceManager;
+import android.util.AttributeSet;
+
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.misc.client.AppClient.ClientType;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings({"deprecation", "unused"})
+public class SpoofStreamingDataSideEffectsPreference extends Preference {
+
+ private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
+ // Because this listener may run before the ReVanced settings fragment updates Settings,
+ // this could show the prior config and not the current.
+ //
+ // Push this call to the end of the main run queue,
+ // so all other listeners are done and Settings is up to date.
+ Utils.runOnMainThread(this::updateUI);
+ };
+
+ public SpoofStreamingDataSideEffectsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public SpoofStreamingDataSideEffectsPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public SpoofStreamingDataSideEffectsPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public SpoofStreamingDataSideEffectsPreference(Context context) {
+ super(context);
+ }
+
+ private void addChangeListener() {
+ Setting.preferences.preferences.registerOnSharedPreferenceChangeListener(listener);
+ }
+
+ private void removeChangeListener() {
+ Setting.preferences.preferences.unregisterOnSharedPreferenceChangeListener(listener);
+ }
+
+ @Override
+ protected void onAttachedToHierarchy(PreferenceManager preferenceManager) {
+ super.onAttachedToHierarchy(preferenceManager);
+ updateUI();
+ addChangeListener();
+ }
+
+ @Override
+ protected void onPrepareForRemoval() {
+ super.onPrepareForRemoval();
+ removeChangeListener();
+ }
+
+ private void updateUI() {
+ final ClientType clientType = Settings.SPOOF_STREAMING_DATA_TYPE.get();
+
+ final String summaryTextKey;
+ if (clientType == ClientType.IOS && Settings.SPOOF_STREAMING_DATA_IOS_SKIP_LIVESTREAM_PLAYBACK.get()) {
+ summaryTextKey = "revanced_spoof_streaming_data_side_effects_ios_skip_livestream_playback";
+ } else {
+ summaryTextKey = "revanced_spoof_streaming_data_side_effects_" + clientType.name().toLowerCase();
+ }
+
+ setSummary(str(summaryTextKey));
+ setEnabled(Settings.SPOOF_STREAMING_DATA.get());
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ThirdPartyYouTubeMusicPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ThirdPartyYouTubeMusicPreference.java
new file mode 100644
index 0000000000..b810cd9a5f
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/ThirdPartyYouTubeMusicPreference.java
@@ -0,0 +1,142 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.annotation.SuppressLint;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.preference.Preference;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.util.AttributeSet;
+import android.util.TypedValue;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TableLayout;
+import android.widget.TableRow;
+
+import java.util.Arrays;
+
+import app.revanced.extension.shared.settings.StringSetting;
+import app.revanced.extension.shared.utils.ResourceUtils;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.utils.ExtendedUtils;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class ThirdPartyYouTubeMusicPreference extends Preference implements Preference.OnPreferenceClickListener {
+
+ private static final StringSetting settings = Settings.THIRD_PARTY_YOUTUBE_MUSIC_PACKAGE_NAME;
+ private static final String[] mEntries = ResourceUtils.getStringArray("revanced_third_party_youtube_music_label");
+ private static final String[] mEntryValues = ResourceUtils.getStringArray("revanced_third_party_youtube_music_package_name");
+
+ @SuppressLint("StaticFieldLeak")
+ private static EditText mEditText;
+ private static String packageName;
+ private static int mClickedDialogEntryIndex;
+
+ private final TextWatcher textWatcher = new TextWatcher() {
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ public void afterTextChanged(Editable s) {
+ packageName = s.toString();
+ mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName);
+ }
+ };
+
+ private void init() {
+ setSelectable(true);
+ setOnPreferenceClickListener(this);
+ }
+
+ public ThirdPartyYouTubeMusicPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+
+ public ThirdPartyYouTubeMusicPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ public ThirdPartyYouTubeMusicPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public ThirdPartyYouTubeMusicPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ packageName = settings.get();
+ mClickedDialogEntryIndex = Arrays.asList(mEntryValues).indexOf(packageName);
+
+ final Context context = getContext();
+ AlertDialog.Builder builder = Utils.getEditTextDialogBuilder(context);
+
+ TableLayout table = new TableLayout(context);
+ table.setOrientation(LinearLayout.HORIZONTAL);
+ table.setPadding(15, 0, 15, 0);
+
+ TableRow row = new TableRow(context);
+
+ mEditText = new EditText(context);
+ mEditText.setHint(settings.defaultValue);
+ mEditText.setText(packageName);
+ mEditText.addTextChangedListener(textWatcher);
+ mEditText.setTextSize(TypedValue.COMPLEX_UNIT_PT, 9);
+ mEditText.setLayoutParams(new TableRow.LayoutParams(TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT, 1f));
+ row.addView(mEditText);
+
+ table.addView(row);
+ builder.setView(table);
+
+ builder.setTitle(str("revanced_third_party_youtube_music_dialog_title"));
+ builder.setSingleChoiceItems(mEntries, mClickedDialogEntryIndex, (dialog, which) -> {
+ mClickedDialogEntryIndex = which;
+ mEditText.setText(mEntryValues[which]);
+ });
+ builder.setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ final String packageName = mEditText.getText().toString().trim();
+ settings.save(packageName);
+ checkPackageIsValid(context, packageName);
+ dialog.dismiss();
+ });
+ builder.setNeutralButton(str("revanced_extended_settings_reset"), (dialog, which) -> settings.resetToDefault());
+ builder.setNegativeButton(android.R.string.cancel, null);
+
+ builder.show();
+
+ return true;
+ }
+
+ private static void checkPackageIsValid(Context context, String packageName) {
+ if (packageName.isEmpty()) {
+ settings.resetToDefault();
+ return;
+ }
+
+ String appName = "";
+ if (mClickedDialogEntryIndex >= 0) {
+ appName = mEntries[mClickedDialogEntryIndex];
+ }
+
+ showToastOrOpenWebsites(context, appName, packageName);
+ }
+
+ private static void showToastOrOpenWebsites(Context context, String appName, String packageName) {
+ if (ExtendedUtils.isPackageEnabled(packageName)) {
+ return;
+ }
+
+ Utils.showToastShort(str("revanced_third_party_youtube_music_not_installed_warning", appName.isEmpty() ? packageName : appName));
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/WatchHistoryStatusPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/WatchHistoryStatusPreference.java
new file mode 100644
index 0000000000..104785fc18
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/WatchHistoryStatusPreference.java
@@ -0,0 +1,81 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.preference.Preference;
+import android.preference.PreferenceManager;
+import android.util.AttributeSet;
+
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.misc.WatchHistoryPatch.WatchHistoryType;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings({"deprecation", "unused"})
+public class WatchHistoryStatusPreference extends Preference {
+
+ private final SharedPreferences.OnSharedPreferenceChangeListener listener = (sharedPreferences, str) -> {
+ // Because this listener may run before the ReVanced settings fragment updates Settings,
+ // this could show the prior config and not the current.
+ //
+ // Push this call to the end of the main run queue,
+ // so all other listeners are done and Settings is up to date.
+ Utils.runOnMainThread(this::updateUI);
+ };
+
+ public WatchHistoryStatusPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ public WatchHistoryStatusPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public WatchHistoryStatusPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public WatchHistoryStatusPreference(Context context) {
+ super(context);
+ }
+
+ private void addChangeListener() {
+ Setting.preferences.preferences.registerOnSharedPreferenceChangeListener(listener);
+ }
+
+ private void removeChangeListener() {
+ Setting.preferences.preferences.unregisterOnSharedPreferenceChangeListener(listener);
+ }
+
+ @Override
+ protected void onAttachedToHierarchy(PreferenceManager preferenceManager) {
+ super.onAttachedToHierarchy(preferenceManager);
+ updateUI();
+ addChangeListener();
+ }
+
+ @Override
+ protected void onPrepareForRemoval() {
+ super.onPrepareForRemoval();
+ removeChangeListener();
+ }
+
+ private void updateUI() {
+ final WatchHistoryType watchHistoryType = Settings.WATCH_HISTORY_TYPE.get();
+ final boolean blockWatchHistory = watchHistoryType == WatchHistoryType.BLOCK;
+ final boolean replaceWatchHistory = watchHistoryType == WatchHistoryType.REPLACE;
+
+ final String summaryTextKey;
+ if (blockWatchHistory) {
+ summaryTextKey = "revanced_watch_history_about_status_blocked";
+ } else if (replaceWatchHistory) {
+ summaryTextKey = "revanced_watch_history_about_status_replaced";
+ } else {
+ summaryTextKey = "revanced_watch_history_about_status_original";
+ }
+
+ setSummary(str(summaryTextKey));
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/WhitelistedChannelsPreference.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/WhitelistedChannelsPreference.java
new file mode 100644
index 0000000000..ffa5d2ba45
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/settings/preference/WhitelistedChannelsPreference.java
@@ -0,0 +1,162 @@
+package app.revanced.extension.youtube.settings.preference;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.preference.Preference;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.View;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+import org.apache.commons.lang3.BooleanUtils;
+
+import java.util.ArrayList;
+
+import app.revanced.extension.youtube.patches.utils.PatchStatus;
+import app.revanced.extension.youtube.utils.ThemeUtils;
+import app.revanced.extension.youtube.whitelist.VideoChannel;
+import app.revanced.extension.youtube.whitelist.Whitelist;
+import app.revanced.extension.youtube.whitelist.Whitelist.WhitelistType;
+
+@SuppressWarnings({"unused", "deprecation"})
+public class WhitelistedChannelsPreference extends Preference implements Preference.OnPreferenceClickListener {
+
+ private static final WhitelistType whitelistTypePlaybackSpeed = WhitelistType.PLAYBACK_SPEED;
+ private static final WhitelistType whitelistTypeSponsorBlock = WhitelistType.SPONSOR_BLOCK;
+ private static final boolean playbackSpeedIncluded = PatchStatus.RememberPlaybackSpeed();
+ private static final boolean sponsorBlockIncluded = PatchStatus.SponsorBlock();
+ private static String[] mEntries;
+ private static WhitelistType[] mEntryValues;
+
+ static {
+ final int entrySize = BooleanUtils.toInteger(playbackSpeedIncluded)
+ + BooleanUtils.toInteger(sponsorBlockIncluded);
+
+ if (entrySize != 0) {
+ mEntries = new String[entrySize];
+ mEntryValues = new WhitelistType[entrySize];
+
+ int index = 0;
+ if (playbackSpeedIncluded) {
+ mEntries[index] = " " + whitelistTypePlaybackSpeed.getFriendlyName() + " ";
+ mEntryValues[index] = whitelistTypePlaybackSpeed;
+ index++;
+ }
+ if (sponsorBlockIncluded) {
+ mEntries[index] = " " + whitelistTypeSponsorBlock.getFriendlyName() + " ";
+ mEntryValues[index] = whitelistTypeSponsorBlock;
+ }
+ }
+ }
+
+ private void init() {
+ setOnPreferenceClickListener(this);
+ }
+
+ public WhitelistedChannelsPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ init();
+ }
+
+ public WhitelistedChannelsPreference(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ init();
+ }
+
+ public WhitelistedChannelsPreference(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ init();
+ }
+
+ public WhitelistedChannelsPreference(Context context) {
+ super(context);
+ init();
+ }
+
+ @Override
+ public boolean onPreferenceClick(Preference preference) {
+ showWhitelistedChannelDialog(getContext());
+
+ return true;
+ }
+
+ public static void showWhitelistedChannelDialog(Context context) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(str("revanced_whitelist_settings_title"));
+ builder.setItems(mEntries, (dialog, which) -> showWhitelistedChannelDialog(context, mEntryValues[which]));
+ builder.setNegativeButton(android.R.string.cancel, null);
+ builder.show();
+ }
+
+ private static void showWhitelistedChannelDialog(Context context, WhitelistType whitelistType) {
+ final ArrayList mEntries = Whitelist.getWhitelistedChannels(whitelistType);
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(context);
+ builder.setTitle(whitelistType.getFriendlyName());
+
+ if (mEntries.isEmpty()) {
+ TextView emptyView = new TextView(context);
+ emptyView.setText(str("revanced_whitelist_empty"));
+ emptyView.setTextAlignment(View.TEXT_ALIGNMENT_TEXT_START);
+ emptyView.setTextSize(16);
+ emptyView.setPadding(60, 40, 60, 0);
+ builder.setView(emptyView);
+ } else {
+ LinearLayout entriesContainer = new LinearLayout(context);
+ entriesContainer.setOrientation(LinearLayout.VERTICAL);
+ for (final VideoChannel entry : mEntries) {
+ String author = entry.getChannelName();
+ View entryView = getEntryView(context, author, v -> new AlertDialog.Builder(context)
+ .setMessage(str("revanced_whitelist_remove_dialog_message", author, whitelistType.getFriendlyName()))
+ .setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ Whitelist.removeFromWhitelist(whitelistType, entry.getChannelId());
+ entriesContainer.removeView(entriesContainer.findViewWithTag(author));
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show());
+ entryView.setTag(author);
+ entriesContainer.addView(entryView);
+ }
+ builder.setView(entriesContainer);
+ }
+
+ builder.setPositiveButton(android.R.string.ok, null);
+ builder.show();
+ }
+
+ private static View getEntryView(Context context, CharSequence entry, View.OnClickListener onDeleteClickListener) {
+ LinearLayout.LayoutParams entryContainerParams = new LinearLayout.LayoutParams(
+ new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT));
+ entryContainerParams.setMargins(60, 40, 60, 0);
+
+ LinearLayout.LayoutParams entryLabelLayoutParams = new LinearLayout.LayoutParams(
+ 0, LinearLayout.LayoutParams.WRAP_CONTENT, 1);
+ entryLabelLayoutParams.gravity = Gravity.CENTER;
+
+ LinearLayout entryContainer = new LinearLayout(context);
+ entryContainer.setOrientation(LinearLayout.HORIZONTAL);
+ entryContainer.setLayoutParams(entryContainerParams);
+
+ TextView entryLabel = new TextView(context);
+ entryLabel.setText(entry);
+ entryLabel.setLayoutParams(entryLabelLayoutParams);
+ entryLabel.setTextSize(16);
+ entryLabel.setOnClickListener(onDeleteClickListener);
+
+ ImageButton deleteButton = new ImageButton(context);
+ deleteButton.setImageDrawable(ThemeUtils.getTrashButtonDrawable());
+ deleteButton.setOnClickListener(onDeleteClickListener);
+ deleteButton.setBackground(null);
+
+ entryContainer.addView(entryLabel);
+ entryContainer.addView(deleteButton);
+ return entryContainer;
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/BottomSheetState.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/BottomSheetState.kt
new file mode 100644
index 0000000000..2d8b513a36
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/BottomSheetState.kt
@@ -0,0 +1,51 @@
+package app.revanced.extension.youtube.shared
+
+import app.revanced.extension.shared.utils.Event
+import app.revanced.extension.shared.utils.Logger
+
+/**
+ * BottomSheetState bottom sheet state.
+ */
+enum class BottomSheetState {
+ CLOSED,
+ OPEN;
+
+ companion object {
+
+ @JvmStatic
+ fun set(enum: BottomSheetState) {
+ if (current != enum) {
+ Logger.printDebug { "BottomSheetState changed to: ${enum.name}" }
+ current = enum
+ }
+ }
+
+ /**
+ * The current bottom sheet state.
+ */
+ @JvmStatic
+ var current
+ get() = currentBottomSheetState
+ private set(value) {
+ currentBottomSheetState = value
+ onChange(currentBottomSheetState)
+ }
+
+ @Volatile // value is read/write from different threads
+ private var currentBottomSheetState = CLOSED
+
+ /**
+ * bottom sheet state change listener
+ */
+ @JvmStatic
+ val onChange = Event()
+ }
+
+ /**
+ * Check if the bottom sheet is [OPEN].
+ * Useful for checking if a bottom sheet is open.
+ */
+ fun isOpen(): Boolean {
+ return this == OPEN
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/LockModeState.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/LockModeState.kt
new file mode 100644
index 0000000000..9a330e687e
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/LockModeState.kt
@@ -0,0 +1,56 @@
+package app.revanced.extension.youtube.shared
+
+import app.revanced.extension.shared.utils.Event
+import app.revanced.extension.shared.utils.Logger
+
+/**
+ * LockModeState.
+ */
+enum class LockModeState {
+ LOCK_MODE_STATE_ENUM_UNKNOWN,
+ LOCK_MODE_STATE_ENUM_UNLOCKED,
+ LOCK_MODE_STATE_ENUM_LOCKED,
+ LOCK_MODE_STATE_ENUM_CAN_UNLOCK,
+ LOCK_MODE_STATE_ENUM_UNLOCK_EXPANDED,
+ LOCK_MODE_STATE_ENUM_LOCKED_TEMPORARY_SUSPENSION;
+
+ companion object {
+
+ private val nameToLockModeState = entries.associateBy { it.name }
+
+ @JvmStatic
+ fun setFromString(enumName: String) {
+ val newType = nameToLockModeState[enumName]
+ if (newType == null) {
+ Logger.printException { "Unknown LockModeState encountered: $enumName" }
+ } else if (current != newType) {
+ Logger.printDebug { "LockModeState changed to: $newType" }
+ current = newType
+ }
+ }
+
+ /**
+ * The current lock mode state.
+ */
+ @JvmStatic
+ var current
+ get() = currentLockModeState
+ private set(value) {
+ currentLockModeState = value
+ onChange(value)
+ }
+
+ @Volatile // value is read/write from different threads
+ private var currentLockModeState = LOCK_MODE_STATE_ENUM_UNKNOWN
+
+ /**
+ * player type change listener
+ */
+ @JvmStatic
+ val onChange = Event()
+ }
+
+ fun isLocked(): Boolean {
+ return this == LOCK_MODE_STATE_ENUM_LOCKED || this == LOCK_MODE_STATE_ENUM_UNLOCK_EXPANDED
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/NavigationBar.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/NavigationBar.java
new file mode 100644
index 0000000000..0f3c071057
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/NavigationBar.java
@@ -0,0 +1,282 @@
+package app.revanced.extension.youtube.shared;
+
+import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton.CREATE;
+
+import android.app.Activity;
+import android.view.View;
+
+import androidx.annotation.Nullable;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.WeakHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings("unused")
+public final class NavigationBar {
+
+ /**
+ * How long to wait for the set nav button latch to be released. Maximum wait time must
+ * be as small as possible while still allowing enough time for the nav bar to update.
+ *
+ * YT calls it's back button handlers out of order,
+ * and litho starts filtering before the navigation bar is updated.
+ *
+ * Fixing this situation and not needlessly waiting requires somehow
+ * detecting if a back button key-press will cause a tab change.
+ *
+ * Typically after pressing the back button, the time between the first litho event and
+ * when the nav button is updated is about 10-20ms. Using 50-100ms here should be enough time
+ * and not noticeable, since YT typically takes 100-200ms (or more) to update the view anyways.
+ *
+ * This issue can also be avoided on a patch by patch basis, by avoiding calls to
+ * {@link NavigationButton#getSelectedNavigationButton()} unless absolutely necessary.
+ */
+ private static final long LATCH_AWAIT_TIMEOUT_MILLISECONDS = 75;
+
+ /**
+ * Used as a workaround to fix the issue of YT calling back button handlers out of order.
+ * Used to hold calls to {@link NavigationButton#getSelectedNavigationButton()}
+ * until the current navigation button can be determined.
+ *
+ * Only used when the hardware back button is pressed.
+ */
+ @Nullable
+ private static volatile CountDownLatch navButtonLatch;
+
+ /**
+ * Map of nav button layout views to Enum type.
+ * No synchronization is needed, and this is always accessed from the main thread.
+ */
+ private static final Map viewToButtonMap = new WeakHashMap<>();
+
+ static {
+ // On app startup litho can start before the navigation bar is initialized.
+ // Force it to wait until the nav bar is updated.
+ createNavButtonLatch();
+ }
+
+ private static void createNavButtonLatch() {
+ navButtonLatch = new CountDownLatch(1);
+ }
+
+ private static void releaseNavButtonLatch() {
+ CountDownLatch latch = navButtonLatch;
+ if (latch != null) {
+ navButtonLatch = null;
+ latch.countDown();
+ }
+ }
+
+ private static void waitForNavButtonLatchIfNeeded() {
+ CountDownLatch latch = navButtonLatch;
+ if (latch == null) {
+ return;
+ }
+
+ if (Utils.isCurrentlyOnMainThread()) {
+ // The latch is released from the main thread, and waiting from the main thread will always timeout.
+ // This situation has only been observed when navigating out of a submenu and not changing tabs.
+ // and for that use case the nav bar does not change so it's safe to return here.
+ Logger.printDebug(() -> "Cannot block main thread waiting for nav button. Using last known navbar button status.");
+ return;
+ }
+
+ try {
+ Logger.printDebug(() -> "Latch wait started");
+ if (latch.await(LATCH_AWAIT_TIMEOUT_MILLISECONDS, TimeUnit.MILLISECONDS)) {
+ // Back button changed the navigation tab.
+ Logger.printDebug(() -> "Latch wait complete");
+ return;
+ }
+
+ // Timeout occurred, and a normal event when pressing the physical back button
+ // does not change navigation tabs.
+ releaseNavButtonLatch(); // Prevent other threads from waiting for no reason.
+ Logger.printDebug(() -> "Latch wait timed out");
+
+ } catch (InterruptedException ex) {
+ Logger.printException(() -> "Latch wait interrupted failure", ex); // Will never happen.
+ }
+ }
+
+ /**
+ * Last YT navigation enum loaded. Not necessarily the active navigation tab.
+ * Always accessed from the main thread.
+ */
+ @Nullable
+ private static String lastYTNavigationEnumName;
+
+ /**
+ * Injection point.
+ */
+ public static void setLastAppNavigationEnum(@Nullable Enum> ytNavigationEnumName) {
+ if (ytNavigationEnumName != null) {
+ lastYTNavigationEnumName = ytNavigationEnumName.name();
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void navigationTabLoaded(final View navigationButtonGroup) {
+ try {
+ String lastEnumName = lastYTNavigationEnumName;
+
+ for (NavigationButton buttonType : NavigationButton.values()) {
+ if (buttonType.ytEnumNames.contains(lastEnumName)) {
+ Logger.printDebug(() -> "navigationTabLoaded: " + lastEnumName);
+ viewToButtonMap.put(navigationButtonGroup, buttonType);
+ navigationTabCreatedCallback(buttonType, navigationButtonGroup);
+ return;
+ }
+ }
+
+ // Log the unknown tab as exception level, only if debug is enabled.
+ // This is because unknown tabs do no harm, and it's only relevant to developers.
+ if (Settings.ENABLE_DEBUG_LOGGING.get()) {
+ Logger.printException(() -> "Unknown tab: " + lastEnumName
+ + " view: " + navigationButtonGroup.getClass());
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "navigationTabLoaded failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * Unique hook just for the 'Create' and 'You' tab.
+ */
+ public static void navigationImageResourceTabLoaded(View view) {
+ // 'You' tab has no YT enum name and the enum hook is not called for it.
+ // Compare the last enum to figure out which tab this actually is.
+ if (CREATE.ytEnumNames.contains(lastYTNavigationEnumName)) {
+ navigationTabLoaded(view);
+ } else {
+ lastYTNavigationEnumName = NavigationButton.LIBRARY.ytEnumNames.get(0);
+ navigationTabLoaded(view);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void navigationTabSelected(View navButtonImageView, boolean isSelected) {
+ try {
+ if (!isSelected) {
+ return;
+ }
+
+ NavigationButton button = viewToButtonMap.get(navButtonImageView);
+
+ if (button == null) { // An unknown tab was selected.
+ // Show a toast only if debug mode is enabled.
+ if (Settings.ENABLE_DEBUG_LOGGING.get()) {
+ Logger.printException(() -> "Unknown navigation view selected: " + navButtonImageView);
+ }
+
+ NavigationButton.selectedNavigationButton = null;
+ return;
+ }
+
+ NavigationButton.selectedNavigationButton = button;
+ Logger.printDebug(() -> "Changed to navigation button: " + button);
+
+ // Release any threads waiting for the selected nav button.
+ releaseNavButtonLatch();
+ } catch (Exception ex) {
+ Logger.printException(() -> "navigationTabSelected failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void onBackPressed(Activity activity) {
+ Logger.printDebug(() -> "Back button pressed");
+ createNavButtonLatch();
+ }
+
+ /**
+ * @noinspection EmptyMethod
+ */
+ private static void navigationTabCreatedCallback(NavigationButton button, View tabView) {
+ // Code is added during patching.
+ }
+
+ public enum NavigationButton {
+ HOME("PIVOT_HOME", "TAB_HOME_CAIRO"),
+ SHORTS("TAB_SHORTS", "TAB_SHORTS_CAIRO"),
+ /**
+ * Create new video tab.
+ * This tab will never be in a selected state, even if the create video UI is on screen.
+ */
+ CREATE("CREATION_TAB_LARGE", "CREATION_TAB_LARGE_CAIRO"),
+ SUBSCRIPTIONS("PIVOT_SUBSCRIPTIONS", "TAB_SUBSCRIPTIONS_CAIRO"),
+ /**
+ * Notifications tab. Only present when
+ * {@link Settings#SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON} is active.
+ */
+ NOTIFICATIONS("TAB_ACTIVITY", "TAB_ACTIVITY_CAIRO"),
+ /**
+ * Library tab, including if the user is in incognito mode or when logged out.
+ */
+ LIBRARY(
+ // Modern library tab with 'You' layout.
+ // The hooked YT code does not use an enum, and a dummy name is used here.
+ "YOU_LIBRARY_DUMMY_PLACEHOLDER_NAME",
+ // User is logged out.
+ "ACCOUNT_CIRCLE",
+ "ACCOUNT_CIRCLE_CAIRO",
+ // User is logged in with incognito mode enabled.
+ "INCOGNITO_CIRCLE",
+ "INCOGNITO_CAIRO",
+ // Old library tab (pre 'You' layout), only present when version spoofing.
+ "VIDEO_LIBRARY_WHITE",
+ // 'You' library tab that is sometimes momentarily loaded.
+ // This might be a temporary tab while the user profile photo is loading,
+ // but its exact purpose is not entirely clear.
+ "PIVOT_LIBRARY"
+ );
+
+ @Nullable
+ private static volatile NavigationButton selectedNavigationButton;
+
+ /**
+ * This will return null only if the currently selected tab is unknown.
+ * This scenario will only happen if the UI has different tabs due to an A/B user test
+ * or YT abruptly changes the navigation layout for some other reason.
+ *
+ * All code calling this method should handle a null return value.
+ *
+ * Due to issues with how YT processes physical back button events,
+ * this patch uses workarounds that can cause this method to take up to 75ms
+ * if the device back button was recently pressed.
+ *
+ * @return The active navigation tab.
+ * If the user is in the upload video UI, this returns tab that is still visually
+ * selected on screen (whatever tab the user was on before tapping the upload button).
+ */
+ @Nullable
+ public static NavigationButton getSelectedNavigationButton() {
+ waitForNavButtonLatchIfNeeded();
+ return selectedNavigationButton;
+ }
+
+ /**
+ * YouTube enum name for this tab.
+ */
+ private final List ytEnumNames;
+
+ NavigationButton(String... ytEnumNames) {
+ this.ytEnumNames = Arrays.asList(ytEnumNames);
+ }
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibility.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibility.kt
new file mode 100644
index 0000000000..e9d5468d4b
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibility.kt
@@ -0,0 +1,43 @@
+package app.revanced.extension.youtube.shared
+
+import app.revanced.extension.shared.utils.Logger
+
+/**
+ * PlayerControls visibility state.
+ */
+enum class PlayerControlsVisibility {
+ PLAYER_CONTROLS_VISIBILITY_UNKNOWN,
+ PLAYER_CONTROLS_VISIBILITY_WILL_HIDE,
+ PLAYER_CONTROLS_VISIBILITY_HIDDEN,
+ PLAYER_CONTROLS_VISIBILITY_WILL_SHOW,
+ PLAYER_CONTROLS_VISIBILITY_SHOWN;
+
+ companion object {
+
+ private val nameToPlayerControlsVisibility = values().associateBy { it.name }
+
+ @JvmStatic
+ fun setFromString(enumName: String) {
+ val state = nameToPlayerControlsVisibility[enumName]
+ if (state == null) {
+ Logger.printException { "Unknown PlayerControlsVisibility encountered: $enumName" }
+ } else if (currentPlayerControlsVisibility != state) {
+ Logger.printDebug { "PlayerControlsVisibility changed to: $state" }
+ currentPlayerControlsVisibility = state
+ }
+ }
+
+ /**
+ * Depending on which hook this is called from,
+ * this value may not be up to date with the actual playback state.
+ */
+ @JvmStatic
+ var current: PlayerControlsVisibility?
+ get() = currentPlayerControlsVisibility
+ private set(value) {
+ currentPlayerControlsVisibility = value
+ }
+
+ private var currentPlayerControlsVisibility: PlayerControlsVisibility? = null
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibilityObserver.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibilityObserver.kt
new file mode 100644
index 0000000000..569292d850
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerControlsVisibilityObserver.kt
@@ -0,0 +1,89 @@
+package app.revanced.extension.youtube.shared
+
+import android.app.Activity
+import android.view.View
+import android.view.ViewGroup
+import app.revanced.extension.shared.utils.ResourceUtils.ResourceType
+import app.revanced.extension.shared.utils.ResourceUtils.getIdentifier
+import java.lang.ref.WeakReference
+
+/**
+ * default implementation of [PlayerControlsVisibilityObserver]
+ *
+ * @param activity activity that contains the controls_layout view
+ */
+class PlayerControlsVisibilityObserverImpl(
+ private val activity: Activity
+) : PlayerControlsVisibilityObserver {
+
+ /**
+ * id of the direct parent of controls_layout, R.id.controls_button_group_layout
+ */
+ private val controlsLayoutParentId =
+ getIdentifier("controls_button_group_layout", ResourceType.ID, activity)
+
+ /**
+ * id of R.id.player_control_play_pause_replay_button_touch_area
+ */
+ private val controlsLayoutId =
+ getIdentifier(
+ "player_control_play_pause_replay_button_touch_area",
+ ResourceType.ID,
+ activity
+ )
+
+ /**
+ * reference to the controls layout view
+ */
+ private var controlsLayoutView = WeakReference(null)
+
+ /**
+ * is the [controlsLayoutView] set to a valid reference of a view?
+ */
+ private val isAttached: Boolean
+ get() {
+ val view = controlsLayoutView.get()
+ return view != null && view.parent != null
+ }
+
+ /**
+ * find and attach the controls_layout view if needed
+ */
+ private fun maybeAttach() {
+ if (isAttached) return
+
+ // find parent, then controls_layout view
+ // this is needed because there may be two views where id=R.id.controls_layout
+ // because why should google confine themselves to their own guidelines...
+ activity.findViewById(controlsLayoutParentId)?.let { parent ->
+ parent.findViewById(controlsLayoutId)?.let {
+ controlsLayoutView = WeakReference(it)
+ }
+ }
+ }
+
+ override val playerControlsVisibility: Int
+ get() {
+ maybeAttach()
+ return controlsLayoutView.get()?.visibility ?: View.GONE
+ }
+
+ override val arePlayerControlsVisible: Boolean
+ get() = playerControlsVisibility == View.VISIBLE
+}
+
+/**
+ * provides the visibility status of the fullscreen player controls_layout view.
+ * this can be used for detecting when the player controls are shown
+ */
+interface PlayerControlsVisibilityObserver {
+ /**
+ * current visibility int of the controls_layout view
+ */
+ val playerControlsVisibility: Int
+
+ /**
+ * is the value of [playerControlsVisibility] equal to [View.VISIBLE]?
+ */
+ val arePlayerControlsVisible: Boolean
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerType.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerType.kt
new file mode 100644
index 0000000000..9bfaffe58d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlayerType.kt
@@ -0,0 +1,150 @@
+package app.revanced.extension.youtube.shared
+
+import app.revanced.extension.shared.utils.Event
+import app.revanced.extension.shared.utils.Logger
+
+/**
+ * WatchWhile player type
+ */
+enum class PlayerType {
+ /**
+ * Either no video, or a Short is playing.
+ */
+ NONE,
+
+ /**
+ * A Short is playing. Occurs if a regular video is first opened
+ * and then a Short is opened (without first closing the regular video).
+ */
+ HIDDEN,
+
+ /**
+ * A regular video is minimized.
+ *
+ * When spoofing to 16.x YouTube and watching a short with a regular video in the background,
+ * the type can be this (and not [HIDDEN]).
+ */
+ WATCH_WHILE_MINIMIZED,
+ WATCH_WHILE_MAXIMIZED,
+ WATCH_WHILE_FULLSCREEN,
+ WATCH_WHILE_SLIDING_MAXIMIZED_FULLSCREEN,
+ WATCH_WHILE_SLIDING_MINIMIZED_MAXIMIZED,
+
+ /**
+ * Player is either sliding to [HIDDEN] state because a Short was opened while a regular video is on screen.
+ * OR
+ * The user has swiped a minimized player away to be closed (and no Short is being opened).
+ */
+ WATCH_WHILE_SLIDING_MINIMIZED_DISMISSED,
+ WATCH_WHILE_SLIDING_FULLSCREEN_DISMISSED,
+
+ /**
+ * Home feed video playback.
+ */
+ INLINE_MINIMAL,
+ VIRTUAL_REALITY_FULLSCREEN,
+ WATCH_WHILE_PICTURE_IN_PICTURE;
+
+ companion object {
+
+ private val nameToPlayerType = entries.associateBy { it.name }
+
+ @JvmStatic
+ fun setFromString(enumName: String) {
+ val newType = nameToPlayerType[enumName]
+ if (newType == null) {
+ Logger.printException { "Unknown PlayerType encountered: $enumName" }
+ } else if (current != newType) {
+ Logger.printDebug { "PlayerType changed to: $newType" }
+ current = newType
+ }
+ }
+
+ /**
+ * The current player type.
+ */
+ @JvmStatic
+ var current
+ get() = currentPlayerType
+ private set(value) {
+ currentPlayerType = value
+ onChange(value)
+ }
+
+ @Volatile // value is read/write from different threads
+ private var currentPlayerType = NONE
+
+ /**
+ * player type change listener
+ */
+ @JvmStatic
+ val onChange = Event()
+ }
+
+ /**
+ * Check if the current player type is [NONE] or [HIDDEN].
+ * Useful to check if a short is currently playing.
+ *
+ * Does not include the first moment after a short is opened when a regular video is minimized on screen,
+ * or while watching a short with a regular video present on a spoofed 16.x version of YouTube.
+ * To include those situations instead use [isNoneHiddenOrMinimized].
+ */
+ fun isNoneOrHidden(): Boolean {
+ return this == NONE || this == HIDDEN
+ }
+
+ /**
+ * Check if the current player type is
+ * [NONE], [HIDDEN], [WATCH_WHILE_SLIDING_MINIMIZED_DISMISSED].
+ *
+ * Useful to check if a Short is being played or opened.
+ *
+ * Usually covers all use cases with no false positives, except if called from some hooks
+ * when spoofing to an old version this will return false even
+ * though a Short is being opened or is on screen (see [isNoneHiddenOrMinimized]).
+ *
+ * @return If nothing, a Short, or a regular video is sliding off screen to a dismissed or hidden state.
+ */
+ fun isNoneHiddenOrSlidingMinimized(): Boolean {
+ return isNoneOrHidden() || this == WATCH_WHILE_SLIDING_MINIMIZED_DISMISSED
+ }
+
+ /**
+ * Check if the current player type is
+ * [NONE], [HIDDEN], [WATCH_WHILE_MINIMIZED], [WATCH_WHILE_SLIDING_MINIMIZED_DISMISSED].
+ *
+ * Useful to check if a Short is being played,
+ * although will return false positive if a regular video is
+ * opened and minimized (and a Short is not playing or being opened).
+ *
+ * Typically used to detect if a Short is playing when the player cannot be in a minimized state,
+ * such as the user interacting with a button or element of the player.
+ *
+ * @return If nothing, a Short, a regular video is sliding off screen to a dismissed or hidden state,
+ * a regular video is minimized (and a new video is not being opened).
+ */
+ fun isNoneHiddenOrMinimized(): Boolean {
+ return isNoneHiddenOrSlidingMinimized() || this == WATCH_WHILE_MINIMIZED
+ }
+
+ /**
+ * Check if the current player type is
+ * [WATCH_WHILE_MAXIMIZED], [WATCH_WHILE_FULLSCREEN].
+ *
+ * Useful to check if a regular video is being played.
+ */
+ fun isMaximizedOrFullscreen(): Boolean {
+ return this == WATCH_WHILE_MAXIMIZED || this == WATCH_WHILE_FULLSCREEN
+ }
+
+ /**
+ * Check if the current player type is
+ * [WATCH_WHILE_FULLSCREEN], [WATCH_WHILE_SLIDING_MAXIMIZED_FULLSCREEN].
+ *
+ * Useful to check if a video is fullscreen.
+ */
+ fun isFullScreenOrSlidingFullScreen(): Boolean {
+ return this == WATCH_WHILE_FULLSCREEN || this == WATCH_WHILE_SLIDING_MAXIMIZED_FULLSCREEN
+ }
+
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlaylistIdPrefix.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlaylistIdPrefix.java
new file mode 100644
index 0000000000..456a561434
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/PlaylistIdPrefix.java
@@ -0,0 +1,32 @@
+package app.revanced.extension.youtube.shared;
+
+import androidx.annotation.NonNull;
+
+public enum PlaylistIdPrefix {
+ /**
+ * To check all available prefixes,
+ * See this document .
+ */
+ ALL_CONTENTS_WITH_TIME_DESCENDING("UU"),
+ ALL_CONTENTS_WITH_POPULAR_DESCENDING("PU"),
+ VIDEOS_ONLY_WITH_TIME_DESCENDING("UULF"),
+ VIDEOS_ONLY_WITH_POPULAR_DESCENDING("UULP"),
+ SHORTS_ONLY_WITH_TIME_DESCENDING("UUSH"),
+ SHORTS_ONLY_WITH_POPULAR_DESCENDING("UUPS"),
+ LIVESTREAMS_ONLY_WITH_TIME_DESCENDING("UULV"),
+ LIVESTREAMS_ONLY_WITH_POPULAR_DESCENDING("UUPV"),
+ ALL_MEMBERSHIPS_CONTENTS("UUMO"),
+ MEMBERSHIPS_VIDEOS_ONLY("UUMF"),
+ MEMBERSHIPS_SHORTS_ONLY("UUMS"),
+ MEMBERSHIPS_LIVESTREAMS_ONLY("UUMV");
+
+ /**
+ * Prefix of playlist id.
+ */
+ @NonNull
+ public final String prefixId;
+
+ PlaylistIdPrefix(@NonNull String prefixId) {
+ this.prefixId = prefixId;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/RootView.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/RootView.java
new file mode 100644
index 0000000000..2dcebb9b82
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/RootView.java
@@ -0,0 +1,41 @@
+package app.revanced.extension.youtube.shared;
+
+import static app.revanced.extension.youtube.patches.components.RelatedVideoFilter.isActionBarVisible;
+
+import android.view.View;
+
+import java.lang.ref.WeakReference;
+
+@SuppressWarnings("unused")
+public final class RootView {
+ private static volatile WeakReference searchBarResultsRef = new WeakReference<>(null);
+
+ /**
+ * Injection point.
+ */
+ public static void searchBarResultsViewLoaded(View searchbarResults) {
+ searchBarResultsRef = new WeakReference<>(searchbarResults);
+ }
+
+ /**
+ * @return If the search bar is on screen. This includes if the player
+ * is on screen and the search results are behind the player (and not visible).
+ * Detecting the search is covered by the player can be done by checking {@link RootView#isPlayerActive()}.
+ */
+ public static boolean isSearchBarActive() {
+ View searchbarResults = searchBarResultsRef.get();
+ return searchbarResults != null && searchbarResults.getParent() != null;
+ }
+
+ public static boolean isPlayerActive() {
+ return PlayerType.getCurrent().isMaximizedOrFullscreen() || isActionBarVisible.get();
+ }
+
+ /**
+ * Get current BrowseId.
+ * Rest of the implementation added by patch.
+ */
+ public static String getBrowseId() {
+ return "";
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/ShortsPlayerState.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/ShortsPlayerState.kt
new file mode 100644
index 0000000000..b0aed2e792
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/ShortsPlayerState.kt
@@ -0,0 +1,51 @@
+package app.revanced.extension.youtube.shared
+
+import app.revanced.extension.shared.utils.Event
+import app.revanced.extension.shared.utils.Logger
+
+/**
+ * ShortsPlayerState shorts player state.
+ */
+enum class ShortsPlayerState {
+ CLOSED,
+ OPEN;
+
+ companion object {
+
+ @JvmStatic
+ fun set(enum: ShortsPlayerState) {
+ if (current != enum) {
+ Logger.printDebug { "ShortsPlayerState changed to: ${enum.name}" }
+ current = enum
+ }
+ }
+
+ /**
+ * The current shorts player state.
+ */
+ @JvmStatic
+ var current
+ get() = currentShortsPlayerState
+ private set(value) {
+ currentShortsPlayerState = value
+ onChange(value)
+ }
+
+ @Volatile // value is read/write from different threads
+ private var currentShortsPlayerState = CLOSED
+
+ /**
+ * shorts player state change listener
+ */
+ @JvmStatic
+ val onChange = Event()
+ }
+
+ /**
+ * Check if the shorts player is [CLOSED].
+ * Useful for checking if a shorts player is closed.
+ */
+ fun isClosed(): Boolean {
+ return this == CLOSED
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoInformation.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoInformation.java
new file mode 100644
index 0000000000..262c9d4d24
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoInformation.java
@@ -0,0 +1,565 @@
+package app.revanced.extension.youtube.shared;
+
+import static app.revanced.extension.shared.utils.ResourceUtils.getString;
+import static app.revanced.extension.shared.utils.Utils.getFormattedTimeStamp;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.utils.AlwaysRepeatPatch;
+
+/**
+ * Hooking class for the current playing video.
+ */
+@SuppressWarnings("all")
+public final class VideoInformation {
+ private static final float DEFAULT_YOUTUBE_PLAYBACK_SPEED = 1.0f;
+ private static final int DEFAULT_YOUTUBE_VIDEO_QUALITY = -2;
+ private static final String DEFAULT_YOUTUBE_VIDEO_QUALITY_STRING = getString("quality_auto");
+ /**
+ * Prefix present in all Short player parameters signature.
+ */
+ private static final String SHORTS_PLAYER_PARAMETERS = "8AEB";
+ /**
+ * Prefix that presents in the player parameter signature when a user manually opens a YouTube Mix and plays a video included in the YouTube Mix.
+ */
+ private static final String YOUTUBE_MIX_PLAYER_PARAMETERS = "8AUB";
+ /**
+ * Prefix present in all YouTube Mix (auto-generated playlist) playlist id.
+ */
+ private static final String YOUTUBE_MIX_PLAYLIST_ID_PREFIX = "RD";
+
+ @NonNull
+ private static String channelId = "";
+ @NonNull
+ private static String channelName = "";
+ @NonNull
+ private static String videoId = "";
+ @NonNull
+ private static String videoTitle = "";
+ private static long videoLength = 0;
+ private static boolean videoIsLiveStream;
+ private static long videoTime = -1;
+
+ @NonNull
+ private static volatile String playerResponseVideoId = "";
+ private static volatile boolean playerResponseVideoIdIsShort;
+ private static volatile boolean videoIdIsShort;
+ private static volatile boolean playerResponseVideoIdIsAutoGeneratedMixPlaylist;
+
+ /**
+ * The current playback speed
+ */
+ private static float playbackSpeed = DEFAULT_YOUTUBE_PLAYBACK_SPEED;
+ /**
+ * The current video quality
+ */
+ private static int videoQuality = DEFAULT_YOUTUBE_VIDEO_QUALITY;
+ /**
+ * The current video quality string
+ */
+ private static String videoQualityString = DEFAULT_YOUTUBE_VIDEO_QUALITY_STRING;
+ /**
+ * The available qualities of the current video in human readable form: [1080, 720, 480]
+ */
+ @Nullable
+ private static List videoQualities;
+
+ /**
+ * Injection point.
+ */
+ public static void initialize() {
+ videoTime = -1;
+ videoLength = 0;
+ playbackSpeed = DEFAULT_YOUTUBE_PLAYBACK_SPEED;
+ Logger.printDebug(() -> "Initialized Player");
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void initializeMdx() {
+ Logger.printDebug(() -> "Initialized Mdx Player");
+ }
+
+ public static boolean seekTo(final long seekTime) {
+ return seekTo(seekTime, getVideoLength());
+ }
+
+ /**
+ * Seek on the current video.
+ * Does not function for playback of Shorts.
+ *
+ * Caution: If called from a videoTimeHook() callback,
+ * this will cause a recursive call into the same videoTimeHook() callback.
+ *
+ * @param seekTime The seekTime to seek the video to.
+ * @return true if the seek was successful.
+ */
+ public static boolean seekTo(final long seekTime, final long videoLength) {
+ Utils.verifyOnMainThread();
+ try {
+ final long videoTime = getVideoTime();
+ final long adjustedSeekTime = getAdjustedSeekTime(seekTime, videoLength);
+
+ Logger.printDebug(() -> "Seeking to: " + getFormattedTimeStamp(adjustedSeekTime));
+
+ // Try regular playback controller first, and it will not succeed if casting.
+ if (overrideVideoTime(adjustedSeekTime)) return true;
+ Logger.printDebug(() -> "seekTo did not succeeded. Trying MXD.");
+ // Else the video is loading or changing videos, or video is casting to a different device.
+
+ // Try calling the seekTo method of the MDX player director (called when casting).
+ // The difference has to be a different second mark in order to avoid infinite skip loops
+ // as the Lounge API only supports seconds.
+ if (adjustedSeekTime / 1000 == videoTime / 1000) {
+ Logger.printDebug(() -> "Skipping seekTo for MDX because seek time is too small "
+ + "(" + (adjustedSeekTime - videoTime) + "ms)");
+ return false;
+ }
+
+ return overrideMDXVideoTime(adjustedSeekTime);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to seek", ex);
+ return false;
+ }
+ }
+
+ // Prevent issues such as play/pause button or autoplay not working.
+ private static long getAdjustedSeekTime(final long seekTime, final long videoLength) {
+ // If the user skips to a section that is 500 ms before the video length,
+ // it will get stuck in a loop.
+ if (videoLength - seekTime > 500) {
+ return seekTime;
+ }
+
+ // Both the current video time and the seekTo are in the last 500ms of the video.
+ if (AlwaysRepeatPatch.alwaysRepeatEnabled()) {
+ // If always-repeat is turned on, just skips to time 0.
+ return 0;
+ } else {
+ // Otherwise, just skips to a time longer than the video length.
+ // Paradoxically, if user skips to a section much longer than the video length, does not get stuck in a loop.
+ return Integer.MAX_VALUE;
+ }
+ }
+
+ /**
+ * Seeks a relative amount. Should always be used over {@link #seekTo(long)}
+ * when the desired seek time is an offset of the current time.
+ *
+ * @noinspection UnusedReturnValue
+ */
+ public static boolean seekToRelative(long seekTime) {
+ Utils.verifyOnMainThread();
+ try {
+ Logger.printDebug(() -> "Seeking relative to: " + seekTime);
+
+ // Try regular playback controller first, and it will not succeed if casting.
+ if (overrideVideoTimeRelative(seekTime)) return true;
+ Logger.printDebug(() -> "seekToRelative did not succeeded. Trying MXD.");
+
+ // Adjust the fine adjustment function so it's at least 1 second before/after.
+ // Otherwise the fine adjustment will do nothing when casting.
+ final long adjustedSeekTime = seekTime < 0
+ ? Math.min(seekTime, -1000)
+ : Math.max(seekTime, 1000);
+
+ return overrideMDXVideoTimeRelative(adjustedSeekTime);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to seek relative", ex);
+ return false;
+ }
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param newlyLoadedChannelId id of the current channel.
+ * @param newlyLoadedChannelName name of the current channel.
+ * @param newlyLoadedVideoId id of the current video.
+ * @param newlyLoadedVideoTitle title of the current video.
+ * @param newlyLoadedVideoLength length of the video in milliseconds.
+ * @param newlyLoadedLiveStreamValue whether the current video is a livestream.
+ */
+ public static void setVideoInformation(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName,
+ @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle,
+ final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) {
+ if (videoId.equals(newlyLoadedVideoId))
+ return;
+
+ channelId = newlyLoadedChannelId;
+ channelName = newlyLoadedChannelName;
+ videoId = newlyLoadedVideoId;
+ videoTitle = newlyLoadedVideoTitle;
+ videoLength = newlyLoadedVideoLength;
+ videoIsLiveStream = newlyLoadedLiveStreamValue;
+
+ Logger.printDebug(() ->
+ "channelId='" +
+ newlyLoadedChannelId +
+ "'\nchannelName='" +
+ newlyLoadedChannelName +
+ "'\nvideoId='" +
+ newlyLoadedVideoId +
+ "'\nvideoTitle='" +
+ newlyLoadedVideoTitle +
+ "'\nvideoLength=" +
+ getFormattedTimeStamp(newlyLoadedVideoLength) +
+ "videoIsLiveStream='" +
+ newlyLoadedLiveStreamValue +
+ "'"
+ );
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param newlyLoadedVideoId id of the current video
+ */
+ public static void setVideoId(@NonNull String newlyLoadedVideoId) {
+ if (videoId.equals(newlyLoadedVideoId))
+ return;
+
+ videoId = newlyLoadedVideoId;
+ }
+
+ /**
+ * Id of the last video opened. Includes Shorts.
+ *
+ * @return The id of the video, or an empty string if no videos have been opened yet.
+ */
+ @NonNull
+ public static String getVideoId() {
+ return videoId;
+ }
+
+ /**
+ * Channel Name of the last video opened. Includes Shorts.
+ *
+ * @return The channel name of the video.
+ */
+ @NonNull
+ public static String getChannelName() {
+ return channelName;
+ }
+
+ /**
+ * ChannelId of the last video opened. Includes Shorts.
+ *
+ * @return The channel id of the video.
+ */
+ @NonNull
+ public static String getChannelId() {
+ return channelId;
+ }
+
+ public static boolean getLiveStreamState() {
+ return videoIsLiveStream;
+ }
+
+
+ /**
+ * Differs from {@link #videoId} as this is the video id for the
+ * last player response received, which may not be the last video opened.
+ *
+ * If Shorts are loading the background, this commonly will be
+ * different from the Short that is currently on screen.
+ *
+ * For most use cases, you should instead use {@link #getVideoId()}.
+ *
+ * @return The id of the last video loaded, or an empty string if no videos have been loaded yet.
+ */
+ @NonNull
+ public static String getPlayerResponseVideoId() {
+ return playerResponseVideoId;
+ }
+
+
+ /**
+ * @return If the last player response video id was a Short.
+ * Includes Shorts shelf items appearing in the feed that are not opened.
+ * @see #lastVideoIdIsShort()
+ */
+ public static boolean lastPlayerResponseIsShort() {
+ return playerResponseVideoIdIsShort;
+ }
+
+ /**
+ * @return If the last player response video id _that was opened_ was a Short.
+ */
+ public static boolean lastVideoIdIsShort() {
+ return videoIdIsShort;
+ }
+
+ /**
+ * @return If the last player response video id was an auto-generated YouTube Mix.
+ */
+ public static boolean lastPlayerResponseIsAutoGeneratedMixPlaylist() {
+ return playerResponseVideoIdIsAutoGeneratedMixPlaylist;
+ }
+
+ /**
+ * @return If the player parameters are for a Short.
+ */
+ public static boolean playerParametersAreShort(@Nullable String playerParameter) {
+ return playerParameter != null && playerParameter.startsWith(SHORTS_PLAYER_PARAMETERS);
+ }
+
+ /**
+ * @return Whether given id belongs to a YouTube Mix.
+ */
+ private static boolean isYoutubeMixId(@Nullable final String playlistId) {
+ return playlistId != null && playlistId.startsWith(YOUTUBE_MIX_PLAYLIST_ID_PREFIX);
+ }
+
+ /**
+ * Whether the user manually opened a YouTube Mix.
+ */
+ public static boolean isMixPlaylistsOpenedByUser(String parameter) {
+ return parameter != null && (parameter.isEmpty() || parameter.startsWith(YOUTUBE_MIX_PLAYER_PARAMETERS));
+ }
+
+ /**
+ * Injection point.
+ */
+ @Nullable
+ public static String newPlayerResponseParameter(@NonNull String videoId, @Nullable String playerParameter,
+ @Nullable String playlistId, boolean isShortAndOpeningOrPlaying) {
+ final boolean isShort = playerParametersAreShort(playerParameter);
+ playerResponseVideoIdIsShort = isShort;
+ if (!isShort || isShortAndOpeningOrPlaying) {
+ if (videoIdIsShort != isShort) {
+ videoIdIsShort = isShort;
+ }
+ }
+ playerResponseVideoIdIsAutoGeneratedMixPlaylist = isYoutubeMixId(playlistId) && !isMixPlaylistsOpenedByUser(playerParameter);
+ return playerParameter; // Return the original value since we are observing and not modifying.
+ }
+
+ /**
+ * Injection point. Called off the main thread.
+ *
+ * @param videoId The id of the last video loaded.
+ */
+ public static void setPlayerResponseVideoId(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) {
+ if (!playerResponseVideoId.equals(videoId)) {
+ playerResponseVideoId = videoId;
+ }
+ }
+
+ /**
+ * @return The current playback speed.
+ */
+ public static float getPlaybackSpeed() {
+ return playbackSpeed;
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param newlyLoadedPlaybackSpeed The current playback speed.
+ */
+ public static void setPlaybackSpeed(float newlyLoadedPlaybackSpeed) {
+ playbackSpeed = newlyLoadedPlaybackSpeed;
+ }
+
+ /**
+ * @return The current video quality.
+ */
+ public static int getVideoQuality() {
+ return videoQuality;
+ }
+
+ /**
+ * @return The current video quality string.
+ */
+ public static String getVideoQualityString() {
+ return videoQualityString;
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param newlyLoadedQuality The current video quality string.
+ */
+ public static void setVideoQuality(String newlyLoadedQuality) {
+ if (newlyLoadedQuality == null) {
+ return;
+ }
+ try {
+ String splitVideoQuality;
+ if (newlyLoadedQuality.contains("p")) {
+ splitVideoQuality = newlyLoadedQuality.split("p")[0];
+ videoQuality = Integer.parseInt(splitVideoQuality);
+ videoQualityString = splitVideoQuality + "p";
+ } else if (newlyLoadedQuality.contains("s")) {
+ splitVideoQuality = newlyLoadedQuality.split("s")[0];
+ videoQuality = Integer.parseInt(splitVideoQuality);
+ videoQualityString = splitVideoQuality + "s";
+ } else {
+ videoQuality = DEFAULT_YOUTUBE_VIDEO_QUALITY;
+ videoQualityString = DEFAULT_YOUTUBE_VIDEO_QUALITY_STRING;
+ }
+ } catch (NumberFormatException ignored) {
+ }
+ }
+
+ /**
+ * @return available video quality.
+ */
+ public static int getAvailableVideoQuality(int preferredQuality) {
+ if (videoQualities != null) {
+ int qualityToUse = videoQualities.get(0); // first element is automatic mode
+ for (Integer quality : videoQualities) {
+ if (quality <= preferredQuality && qualityToUse < quality) {
+ qualityToUse = quality;
+ }
+ }
+ preferredQuality = qualityToUse;
+ }
+ return preferredQuality;
+ }
+
+ /**
+ * Injection point.
+ *
+ * @param qualities Video qualities available, ordered from largest to smallest, with index 0 being the 'automatic' value of -2
+ */
+ public static void setVideoQualityList(Object[] qualities) {
+ try {
+ if (videoQualities == null || videoQualities.size() != qualities.length) {
+ videoQualities = new ArrayList<>(qualities.length);
+ for (Object streamQuality : qualities) {
+ for (Field field : streamQuality.getClass().getFields()) {
+ if (field.getType().isAssignableFrom(Integer.TYPE)
+ && field.getName().length() <= 2) {
+ videoQualities.add(field.getInt(streamQuality));
+ }
+ }
+ }
+ Logger.printDebug(() -> "videoQualities: " + videoQualities);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to set quality list", ex);
+ }
+ }
+
+ /**
+ * Length of the current video playing. Includes Shorts.
+ *
+ * @return The length of the video in milliseconds.
+ * If the video is not yet loaded, or if the video is playing in the background with no video visible,
+ * then this returns zero.
+ */
+ public static long getVideoLength() {
+ return videoLength;
+ }
+
+ /**
+ * Playback time of the current video playing. Includes Shorts.
+ *
+ * Value will lag behind the actual playback time by a variable amount based on the playback speed.
+ *
+ * If playback speed is 2.0x, this value may be up to 2000ms behind the actual playback time.
+ * If playback speed is 1.0x, this value may be up to 1000ms behind the actual playback time.
+ * If playback speed is 0.5x, this value may be up to 500ms behind the actual playback time.
+ * Etc.
+ *
+ * @return The time of the video in milliseconds. -1 if not set yet.
+ */
+ public static long getVideoTime() {
+ return videoTime;
+ }
+
+ public static long getVideoTimeInSeconds() {
+ return videoTime / 1000;
+ }
+
+ /**
+ * Injection point.
+ * Called on the main thread every 100ms.
+ *
+ * @param time The current playback time of the video in milliseconds.
+ */
+ public static void setVideoTime(final long time) {
+ videoTime = time;
+ Logger.printDebug(() -> "setVideoTime: " + getFormattedTimeStamp(time));
+ }
+
+ /**
+ * @return If the playback is at the end of the video.
+ *
+ * If video is playing in the background with no video visible,
+ * this always returns false (even if the video is actually at the end).
+ *
+ * This is equivalent to checking for {@link VideoState#ENDED},
+ * but can give a more up-to-date result for code calling from some hooks.
+ * @see VideoState
+ */
+ public static boolean isAtEndOfVideo() {
+ return videoTime >= videoLength && videoLength > 0;
+ }
+
+ /**
+ * Overrides the current playback speed.
+ * Rest of the implementation added by patch.
+ */
+ public static void overridePlaybackSpeed(float speedOverride) {
+ Logger.printDebug(() -> "Overriding playback speed to: " + speedOverride);
+ }
+
+ /**
+ * Overrides the current quality.
+ * Rest of the implementation added by patch.
+ */
+ public static void overrideVideoQuality(int qualityOverride) {
+ Logger.printDebug(() -> "Overriding video quality to: " + qualityOverride);
+ }
+
+ /**
+ * Overrides the current video time by seeking.
+ * Rest of the implementation added by patch.
+ */
+ public static boolean overrideVideoTime(final long seekTime) {
+ // These instructions are ignored by patch.
+ Logger.printDebug(() -> "Seeking to " + seekTime);
+ return false;
+ }
+
+ /**
+ * Overrides the current video time by seeking. (MDX player)
+ * Rest of the implementation added by patch.
+ */
+ public static boolean overrideMDXVideoTime(final long seekTime) {
+ // These instructions are ignored by patch.
+ Logger.printDebug(() -> "Seeking to " + seekTime);
+ return false;
+ }
+
+ /**
+ * Overrides the current video time by seeking relative.
+ * Rest of the implementation added by patch.
+ */
+ public static boolean overrideVideoTimeRelative(final long seekTime) {
+ // These instructions are ignored by patch.
+ Logger.printDebug(() -> "Seeking to " + seekTime);
+ return false;
+ }
+
+ /**
+ * Overrides the current video time by seeking relative. (MDX player)
+ * Rest of the implementation added by patch.
+ */
+ public static boolean overrideMDXVideoTimeRelative(final long seekTime) {
+ // These instructions are ignored by patch.
+ Logger.printDebug(() -> "Seeking to " + seekTime);
+ return false;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoState.kt b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoState.kt
new file mode 100644
index 0000000000..4e1888a7cc
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/shared/VideoState.kt
@@ -0,0 +1,44 @@
+package app.revanced.extension.youtube.shared
+
+import app.revanced.extension.shared.utils.Logger
+
+/**
+ * VideoState playback state.
+ */
+enum class VideoState {
+ NEW,
+ PLAYING,
+ PAUSED,
+ RECOVERABLE_ERROR,
+ UNRECOVERABLE_ERROR,
+ ENDED;
+
+ companion object {
+
+ private val nameToVideoState = entries.associateBy { it.name }
+
+ @JvmStatic
+ fun setFromString(enumName: String) {
+ val state = nameToVideoState[enumName]
+ if (state == null) {
+ Logger.printException { "Unknown VideoState encountered: $enumName" }
+ } else if (current != state) {
+ Logger.printDebug { "VideoState changed to: $state" }
+ current = state
+ }
+ }
+
+ /**
+ * Depending on which hook this is called from,
+ * this value may not be up to date with the actual playback state.
+ */
+ @JvmStatic
+ var current
+ get() = currentVideoState
+ private set(value) {
+ currentVideoState = value
+ }
+
+ private var currentVideoState: VideoState? = null
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java
new file mode 100644
index 0000000000..948f2d0441
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SegmentPlaybackController.java
@@ -0,0 +1,797 @@
+package app.revanced.extension.youtube.sponsorblock;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+import static app.revanced.extension.youtube.utils.VideoUtils.getFormattedTimeStamp;
+
+import android.annotation.SuppressLint;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.text.TextUtils;
+import android.util.TypedValue;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.PlayerType;
+import app.revanced.extension.youtube.shared.VideoInformation;
+import app.revanced.extension.youtube.shared.VideoState;
+import app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour;
+import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory;
+import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment;
+import app.revanced.extension.youtube.sponsorblock.requests.SBRequester;
+import app.revanced.extension.youtube.sponsorblock.ui.SponsorBlockViewController;
+import app.revanced.extension.youtube.whitelist.Whitelist;
+
+/**
+ * Handles showing, scheduling, and skipping of all {@link SponsorSegment} for the current video.
+ *
+ * Class is not thread safe. All methods must be called on the main thread unless otherwise specified.
+ */
+@SuppressWarnings("unused")
+public class SegmentPlaybackController {
+ /**
+ * Length of time to show a skip button for a highlight segment,
+ * or a regular segment if {@link Settings#SB_AUTO_HIDE_SKIP_BUTTON} is enabled.
+ *
+ * Effectively this value is rounded up to the next second.
+ */
+ private static final long DURATION_TO_SHOW_SKIP_BUTTON = 3800;
+
+ /*
+ * Highlight segments have zero length as they are a point in time.
+ * Draw them on screen using a fixed width bar.
+ * Value is independent of device dpi.
+ */
+ private static final int HIGHLIGHT_SEGMENT_DRAW_BAR_WIDTH = 7;
+ /**
+ * Used to prevent re-showing a previously hidden skip button when exiting an embedded segment.
+ * Only used when {@link Settings#SB_AUTO_HIDE_SKIP_BUTTON} is enabled.
+ *
+ * A collection of segments that have automatically hidden the skip button for, and all segments in this list
+ * contain the current video time. Segment are removed when playback exits the segment.
+ */
+ private static final List hiddenSkipSegmentsForCurrentVideoTime = new ArrayList<>();
+ @NonNull
+ private static String videoId = "";
+ private static long videoLength = 0;
+
+ @Nullable
+ private static SponsorSegment[] segments;
+ /**
+ * Highlight segment, if one exists and the skip behavior is not set to {@link CategoryBehaviour#SHOW_IN_SEEKBAR}.
+ */
+ @Nullable
+ private static SponsorSegment highlightSegment;
+ /**
+ * Because loading can take time, show the skip to highlight for a few seconds after the segments load.
+ * This is the system time (in milliseconds) to no longer show the initial display skip to highlight.
+ * Value will be zero if no highlight segment exists, or if the system time to show the highlight has passed.
+ */
+ private static long highlightSegmentInitialShowEndTime;
+ /**
+ * Currently playing (non-highlight) segment that user can manually skip.
+ */
+ @Nullable
+ private static SponsorSegment segmentCurrentlyPlaying;
+ /**
+ * Currently playing manual skip segment that is scheduled to hide.
+ * This will always be NULL or equal to {@link #segmentCurrentlyPlaying}.
+ */
+ @Nullable
+ private static SponsorSegment scheduledHideSegment;
+ /**
+ * Upcoming segment that is scheduled to either autoskip or show the manual skip button.
+ */
+ @Nullable
+ private static SponsorSegment scheduledUpcomingSegment;
+ /**
+ * System time (in milliseconds) of when to hide the skip button of {@link #segmentCurrentlyPlaying}.
+ * Value is zero if playback is not inside a segment ({@link #segmentCurrentlyPlaying} is null),
+ * or if {@link Settings#SB_AUTO_HIDE_SKIP_BUTTON} is not enabled.
+ */
+ private static long skipSegmentButtonEndTime;
+
+ @Nullable
+ private static String timeWithoutSegments;
+
+ private static int sponsorBarAbsoluteLeft;
+ private static int sponsorAbsoluteBarRight;
+ private static int sponsorBarThickness;
+ private static SponsorSegment lastSegmentSkipped;
+ private static long lastSegmentSkippedTime;
+ private static int toastNumberOfSegmentsSkipped;
+ @Nullable
+ private static SponsorSegment toastSegmentSkipped;
+ private static int highlightSegmentTimeBarScreenWidth = -1; // actual pixel width to use
+
+ @Nullable
+ static SponsorSegment[] getSegments() {
+ return segments;
+ }
+
+ private static void setSegments(@NonNull SponsorSegment[] videoSegments) {
+ Arrays.sort(videoSegments);
+ segments = videoSegments;
+ calculateTimeWithoutSegments();
+
+ if (SegmentCategory.HIGHLIGHT.behaviour == CategoryBehaviour.SKIP_AUTOMATICALLY
+ || SegmentCategory.HIGHLIGHT.behaviour == CategoryBehaviour.MANUAL_SKIP) {
+ for (SponsorSegment segment : videoSegments) {
+ if (segment.category == SegmentCategory.HIGHLIGHT) {
+ highlightSegment = segment;
+ return;
+ }
+ }
+ }
+ highlightSegment = null;
+ }
+
+ static void addUnsubmittedSegment(@NonNull SponsorSegment segment) {
+ Objects.requireNonNull(segment);
+ if (segments == null) {
+ segments = new SponsorSegment[1];
+ } else {
+ segments = Arrays.copyOf(segments, segments.length + 1);
+ }
+ segments[segments.length - 1] = segment;
+ setSegments(segments);
+ }
+
+ static void removeUnsubmittedSegments() {
+ if (segments == null || segments.length == 0) {
+ return;
+ }
+ List replacement = new ArrayList<>();
+ for (SponsorSegment segment : segments) {
+ if (segment.category != SegmentCategory.UNSUBMITTED) {
+ replacement.add(segment);
+ }
+ }
+ if (replacement.size() != segments.length) {
+ setSegments(replacement.toArray(new SponsorSegment[0]));
+ }
+ }
+
+ public static boolean videoHasSegments() {
+ return segments != null && segments.length > 0;
+ }
+
+ /**
+ * Clears all downloaded data.
+ */
+ public static void clearData() {
+ videoId = "";
+ videoLength = 0;
+ segments = null;
+ highlightSegment = null;
+ highlightSegmentInitialShowEndTime = 0;
+ timeWithoutSegments = null;
+ segmentCurrentlyPlaying = null;
+ scheduledUpcomingSegment = null;
+ scheduledHideSegment = null;
+ skipSegmentButtonEndTime = 0;
+ toastSegmentSkipped = null;
+ toastNumberOfSegmentsSkipped = 0;
+ hiddenSkipSegmentsForCurrentVideoTime.clear();
+ }
+
+ /**
+ * Injection point.
+ * Initializes SponsorBlock when the video player starts playing a new video.
+ */
+ public static void initialize() {
+ try {
+ Utils.verifyOnMainThread();
+ SponsorBlockSettings.initialize();
+ clearData();
+ SponsorBlockViewController.hideAll();
+ SponsorBlockUtils.clearUnsubmittedSegmentTimes();
+ Logger.printDebug(() -> "Initialized SponsorBlock");
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to initialize SponsorBlock", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void newVideoStarted(@NonNull String newlyLoadedChannelId, @NonNull String newlyLoadedChannelName,
+ @NonNull String newlyLoadedVideoId, @NonNull String newlyLoadedVideoTitle,
+ final long newlyLoadedVideoLength, boolean newlyLoadedLiveStreamValue) {
+ try {
+ if (Objects.equals(videoId, newlyLoadedVideoId)) {
+ return;
+ }
+ clearData();
+ if (!Settings.SB_ENABLED.get()) {
+ return;
+ }
+ if (PlayerType.getCurrent().isNoneOrHidden()) {
+ Logger.printDebug(() -> "ignoring Short");
+ return;
+ }
+ if (Utils.isNetworkNotConnected()) {
+ Logger.printDebug(() -> "Network not connected, ignoring video");
+ return;
+ }
+
+ videoId = newlyLoadedVideoId;
+ videoLength = newlyLoadedVideoLength;
+ Logger.printDebug(() -> "newVideoStarted: " + newlyLoadedVideoId);
+
+ if (Whitelist.isChannelWhitelistedSponsorBlock(newlyLoadedChannelId)) {
+ return;
+ }
+
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ executeDownloadSegments(newlyLoadedVideoId);
+ } catch (Exception e) {
+ Logger.printException(() -> "Failed to download segments", e);
+ }
+ });
+ } catch (Exception ex) {
+ Logger.printException(() -> "setCurrentVideoId failure", ex);
+ }
+ }
+
+ /**
+ * Id of the last video opened. Includes Shorts.
+ *
+ * @return The id of the video, or an empty string if no videos have been opened yet.
+ */
+ @NonNull
+ public static String getVideoId() {
+ return videoId;
+ }
+
+ /**
+ * Length of the current video playing. Includes Shorts.
+ *
+ * @return The length of the video in milliseconds.
+ * If the video is not yet loaded, or if the video is playing in the background with no video visible,
+ * then this returns zero.
+ */
+ public static long getVideoLength() {
+ return videoLength;
+ }
+
+ /**
+ * Must be called off main thread
+ */
+ static void executeDownloadSegments(@NonNull String newlyLoadedVideoId) {
+ Objects.requireNonNull(newlyLoadedVideoId);
+ try {
+ SponsorSegment[] segments = SBRequester.getSegments(newlyLoadedVideoId);
+
+ Utils.runOnMainThread(() -> {
+ if (!newlyLoadedVideoId.equals(videoId)) {
+ // user changed videos before get segments network call could complete
+ Logger.printDebug(() -> "Ignoring segments for prior video: " + newlyLoadedVideoId);
+ return;
+ }
+ setSegments(segments);
+
+ final long videoTime = VideoInformation.getVideoTime();
+ if (highlightSegment != null) {
+ // If the current video time is before the highlight.
+ final long timeUntilHighlight = highlightSegment.start - videoTime;
+ if (timeUntilHighlight > 0) {
+ if (highlightSegment.shouldAutoSkip()) {
+ skipSegment(highlightSegment, false);
+ return;
+ }
+ highlightSegmentInitialShowEndTime = System.currentTimeMillis() + Math.min(
+ (long) (timeUntilHighlight / VideoInformation.getPlaybackSpeed()),
+ DURATION_TO_SHOW_SKIP_BUTTON);
+ }
+ }
+
+ // check for any skips now, instead of waiting for the next update to setVideoTime()
+ setVideoTime(videoTime);
+ });
+ } catch (Exception ex) {
+ Logger.printException(() -> "executeDownloadSegments failure", ex);
+ }
+ }
+
+ /**
+ * Injection point.
+ * Updates SponsorBlock every 100ms.
+ * When changing videos, this is first called with value 0 and then the video is changed.
+ */
+ public static void setVideoTime(long millis) {
+ try {
+ if (!Settings.SB_ENABLED.get()
+ || PlayerType.getCurrent().isNoneOrHidden() // Shorts playback.
+ || segments == null || segments.length == 0) {
+ return;
+ }
+ Logger.printDebug(() -> "setVideoTime: " + getFormattedTimeStamp(millis));
+
+ updateHiddenSegments(millis);
+
+ final float playbackSpeed = VideoInformation.getPlaybackSpeed();
+ // Amount of time to look ahead for the next segment,
+ // and the threshold to determine if a scheduled show/hide is at the correct video time when it's run.
+ //
+ // This value must be greater than largest time between calls to this method (1000ms),
+ // and must be adjusted for the video speed.
+ //
+ // To debug the stale skip logic, set this to a very large value (5000 or more)
+ // then try manually seeking just before playback reaches a segment skip.
+ final long speedAdjustedTimeThreshold = (long) (playbackSpeed * 1000);
+ final long startTimerLookAheadThreshold = millis + speedAdjustedTimeThreshold;
+
+ SponsorSegment foundSegmentCurrentlyPlaying = null;
+ SponsorSegment foundUpcomingSegment = null;
+
+ for (final SponsorSegment segment : segments) {
+ if (segment.category.behaviour == CategoryBehaviour.SHOW_IN_SEEKBAR
+ || segment.category.behaviour == CategoryBehaviour.IGNORE
+ || segment.category == SegmentCategory.HIGHLIGHT) {
+ continue;
+ }
+ if (segment.end <= millis) {
+ continue; // past this segment
+ }
+
+ if (segment.start <= millis) {
+ // we are in the segment!
+ if (segment.shouldAutoSkip()) {
+ skipSegment(segment, false);
+ return; // must return, as skipping causes a recursive call back into this method
+ }
+
+ // first found segment, or it's an embedded segment and fully inside the outer segment
+ if (foundSegmentCurrentlyPlaying == null || foundSegmentCurrentlyPlaying.containsSegment(segment)) {
+ // If the found segment is not currently displayed, then do not show if the segment is nearly over.
+ // This check prevents the skip button text from rapidly changing when multiple segments end at nearly the same time.
+ // Also prevents showing the skip button if user seeks into the last 800ms of the segment.
+ final long minMillisOfSegmentRemainingThreshold = 800;
+ if (segmentCurrentlyPlaying == segment
+ || !segment.endIsNear(millis, minMillisOfSegmentRemainingThreshold)) {
+ foundSegmentCurrentlyPlaying = segment;
+ } else {
+ Logger.printDebug(() -> "Ignoring segment that ends very soon: " + segment);
+ }
+ }
+ // Keep iterating and looking. There may be an upcoming autoskip,
+ // or there may be another smaller segment nested inside this segment
+ continue;
+ }
+
+ // segment is upcoming
+ if (startTimerLookAheadThreshold < segment.start) {
+ break; // segment is not close enough to schedule, and no segments after this are of interest
+ }
+ if (segment.shouldAutoSkip()) { // upcoming autoskip
+ foundUpcomingSegment = segment;
+ break; // must stop here
+ }
+
+ // upcoming manual skip
+
+ // do not schedule upcoming segment, if it is not fully contained inside the current segment
+ if ((foundSegmentCurrentlyPlaying == null || foundSegmentCurrentlyPlaying.containsSegment(segment))
+ // use the most inner upcoming segment
+ && (foundUpcomingSegment == null || foundUpcomingSegment.containsSegment(segment))) {
+
+ // Only schedule, if the segment start time is not near the end time of the current segment.
+ // This check is needed to prevent scheduled hide and show from clashing with each other.
+ // Instead the upcoming segment will be handled when the current segment scheduled hide calls back into this method.
+ final long minTimeBetweenStartEndOfSegments = 1000;
+ if (foundSegmentCurrentlyPlaying == null
+ || !foundSegmentCurrentlyPlaying.endIsNear(segment.start, minTimeBetweenStartEndOfSegments)) {
+ foundUpcomingSegment = segment;
+ } else {
+ Logger.printDebug(() -> "Not scheduling segment (start time is near end of current segment): " + segment);
+ }
+ }
+ }
+
+ if (highlightSegment != null) {
+ if (millis < DURATION_TO_SHOW_SKIP_BUTTON || (highlightSegmentInitialShowEndTime != 0
+ && System.currentTimeMillis() < highlightSegmentInitialShowEndTime)) {
+ SponsorBlockViewController.showSkipHighlightButton(highlightSegment);
+ } else {
+ highlightSegmentInitialShowEndTime = 0;
+ SponsorBlockViewController.hideSkipHighlightButton();
+ }
+ }
+
+ if (segmentCurrentlyPlaying != foundSegmentCurrentlyPlaying) {
+ setSegmentCurrentlyPlaying(foundSegmentCurrentlyPlaying);
+ } else if (foundSegmentCurrentlyPlaying != null
+ && skipSegmentButtonEndTime != 0 && skipSegmentButtonEndTime <= System.currentTimeMillis()) {
+ Logger.printDebug(() -> "Auto hiding skip button for segment: " + segmentCurrentlyPlaying);
+ skipSegmentButtonEndTime = 0;
+ hiddenSkipSegmentsForCurrentVideoTime.add(foundSegmentCurrentlyPlaying);
+ SponsorBlockViewController.hideSkipSegmentButton();
+ }
+
+ // schedule a hide, only if the segment end is near
+ final SponsorSegment segmentToHide =
+ (foundSegmentCurrentlyPlaying != null && foundSegmentCurrentlyPlaying.endIsNear(millis, speedAdjustedTimeThreshold))
+ ? foundSegmentCurrentlyPlaying
+ : null;
+
+ if (scheduledHideSegment != segmentToHide) {
+ if (segmentToHide == null) {
+ Logger.printDebug(() -> "Clearing scheduled hide: " + scheduledHideSegment);
+ scheduledHideSegment = null;
+ } else {
+ scheduledHideSegment = segmentToHide;
+ Logger.printDebug(() -> "Scheduling hide segment: " + segmentToHide + " playbackSpeed: " + playbackSpeed);
+ final long delayUntilHide = (long) ((segmentToHide.end - millis) / playbackSpeed);
+ Utils.runOnMainThreadDelayed(() -> {
+ if (scheduledHideSegment != segmentToHide) {
+ Logger.printDebug(() -> "Ignoring old scheduled hide segment: " + segmentToHide);
+ return;
+ }
+ scheduledHideSegment = null;
+ if (VideoState.getCurrent() != VideoState.PLAYING) {
+ Logger.printDebug(() -> "Ignoring scheduled hide segment as video is paused: " + segmentToHide);
+ return;
+ }
+
+ final long videoTime = VideoInformation.getVideoTime();
+ if (!segmentToHide.endIsNear(videoTime, speedAdjustedTimeThreshold)) {
+ // current video time is not what's expected. User paused playback
+ Logger.printDebug(() -> "Ignoring outdated scheduled hide: " + segmentToHide
+ + " videoInformation time: " + videoTime);
+ return;
+ }
+ Logger.printDebug(() -> "Running scheduled hide segment: " + segmentToHide);
+ // Need more than just hide the skip button, as this may have been an embedded segment
+ // Instead call back into setVideoTime to check everything again.
+ // Should not use VideoInformation time as it is less accurate,
+ // but this scheduled handler was scheduled precisely so we can just use the segment end time
+ setSegmentCurrentlyPlaying(null);
+ setVideoTime(segmentToHide.end);
+ }, delayUntilHide);
+ }
+ }
+
+ if (scheduledUpcomingSegment != foundUpcomingSegment) {
+ if (foundUpcomingSegment == null) {
+ Logger.printDebug(() -> "Clearing scheduled segment: " + scheduledUpcomingSegment);
+ scheduledUpcomingSegment = null;
+ } else {
+ scheduledUpcomingSegment = foundUpcomingSegment;
+ final SponsorSegment segmentToSkip = foundUpcomingSegment;
+
+ Logger.printDebug(() -> "Scheduling segment: " + segmentToSkip + " playbackSpeed: " + playbackSpeed);
+ final long delayUntilSkip = (long) ((segmentToSkip.start - millis) / playbackSpeed);
+ Utils.runOnMainThreadDelayed(() -> {
+ if (scheduledUpcomingSegment != segmentToSkip) {
+ Logger.printDebug(() -> "Ignoring old scheduled segment: " + segmentToSkip);
+ return;
+ }
+ scheduledUpcomingSegment = null;
+ if (VideoState.getCurrent() != VideoState.PLAYING) {
+ Logger.printDebug(() -> "Ignoring scheduled hide segment as video is paused: " + segmentToSkip);
+ return;
+ }
+
+ final long videoTime = VideoInformation.getVideoTime();
+ if (!segmentToSkip.startIsNear(videoTime, speedAdjustedTimeThreshold)) {
+ // current video time is not what's expected. User paused playback
+ Logger.printDebug(() -> "Ignoring outdated scheduled segment: " + segmentToSkip
+ + " videoInformation time: " + videoTime);
+ return;
+ }
+ if (segmentToSkip.shouldAutoSkip()) {
+ Logger.printDebug(() -> "Running scheduled skip segment: " + segmentToSkip);
+ skipSegment(segmentToSkip, false);
+ } else {
+ Logger.printDebug(() -> "Running scheduled show segment: " + segmentToSkip);
+ setSegmentCurrentlyPlaying(segmentToSkip);
+ }
+ }, delayUntilSkip);
+ }
+ }
+ } catch (Exception e) {
+ Logger.printException(() -> "setVideoTime failure", e);
+ }
+ }
+
+ /**
+ * Removes all previously hidden segments that are not longer contained in the given video time.
+ */
+ private static void updateHiddenSegments(long currentVideoTime) {
+ Iterator i = hiddenSkipSegmentsForCurrentVideoTime.iterator();
+ while (i.hasNext()) {
+ SponsorSegment hiddenSegment = i.next();
+ if (!hiddenSegment.containsTime(currentVideoTime)) {
+ Logger.printDebug(() -> "Resetting hide skip button: " + hiddenSegment);
+ i.remove();
+ }
+ }
+ }
+
+ private static void setSegmentCurrentlyPlaying(@Nullable SponsorSegment segment) {
+ if (segment == null) {
+ if (segmentCurrentlyPlaying != null)
+ Logger.printDebug(() -> "Hiding segment: " + segmentCurrentlyPlaying);
+ segmentCurrentlyPlaying = null;
+ skipSegmentButtonEndTime = 0;
+ SponsorBlockViewController.hideSkipSegmentButton();
+ return;
+ }
+ segmentCurrentlyPlaying = segment;
+ skipSegmentButtonEndTime = 0;
+ if (Settings.SB_AUTO_HIDE_SKIP_BUTTON.get()) {
+ if (hiddenSkipSegmentsForCurrentVideoTime.contains(segment)) {
+ // Playback exited a nested segment and the outer segment skip button was previously hidden.
+ Logger.printDebug(() -> "Ignoring previously auto-hidden segment: " + segment);
+ SponsorBlockViewController.hideSkipSegmentButton();
+ return;
+ }
+ skipSegmentButtonEndTime = System.currentTimeMillis() + DURATION_TO_SHOW_SKIP_BUTTON;
+ }
+ Logger.printDebug(() -> "Showing segment: " + segment);
+ SponsorBlockViewController.showSkipSegmentButton(segment);
+ }
+
+ private static void skipSegment(@NonNull SponsorSegment segmentToSkip, boolean userManuallySkipped) {
+ try {
+ SponsorBlockViewController.hideSkipHighlightButton();
+ SponsorBlockViewController.hideSkipSegmentButton();
+
+ final long now = System.currentTimeMillis();
+ if (lastSegmentSkipped == segmentToSkip) {
+ // If trying to seek to end of the video, YouTube can seek just before of the actual end.
+ // (especially if the video does not end on a whole second boundary).
+ // This causes additional segment skip attempts, even though it cannot seek any closer to the desired time.
+ // Check for and ignore repeated skip attempts of the same segment over a small time period.
+ final long minTimeBetweenSkippingSameSegment = Math.max(500, (long) (500 / VideoInformation.getPlaybackSpeed()));
+ if (now - lastSegmentSkippedTime < minTimeBetweenSkippingSameSegment) {
+ Logger.printDebug(() -> "Ignoring skip segment request (already skipped as close as possible): " + segmentToSkip);
+ return;
+ }
+ }
+
+ Logger.printDebug(() -> "Skipping segment: " + segmentToSkip);
+ lastSegmentSkipped = segmentToSkip;
+ lastSegmentSkippedTime = now;
+ setSegmentCurrentlyPlaying(null);
+ scheduledHideSegment = null;
+ scheduledUpcomingSegment = null;
+ if (segmentToSkip == highlightSegment) {
+ highlightSegmentInitialShowEndTime = 0;
+ }
+
+ // If the seek is successful, then the seek causes a recursive call back into this class.
+ final boolean seekSuccessful = VideoInformation.seekTo(segmentToSkip.end, getVideoLength());
+ if (!seekSuccessful) {
+ // can happen when switching videos and is normal
+ Logger.printDebug(() -> "Could not skip segment (seek unsuccessful): " + segmentToSkip);
+ return;
+ }
+
+ final boolean videoIsPaused = VideoState.getCurrent() == VideoState.PAUSED;
+ if (!userManuallySkipped) {
+ // check for any smaller embedded segments, and count those as autoskipped
+ final boolean showSkipToast = Settings.SB_TOAST_ON_SKIP.get();
+ for (final SponsorSegment otherSegment : Objects.requireNonNull(segments)) {
+ if (segmentToSkip.end < otherSegment.start) {
+ break; // no other segments can be contained
+ }
+ if (otherSegment == segmentToSkip ||
+ (otherSegment.category != SegmentCategory.HIGHLIGHT && segmentToSkip.containsSegment(otherSegment))) {
+ otherSegment.didAutoSkipped = true;
+ // Do not show a toast if the user is scrubbing thru a paused video.
+ // Cannot do this video state check in setTime or earlier in this method, as the video state may not be up to date.
+ // So instead, only hide toasts because all other skip logic done while paused causes no harm.
+ if (showSkipToast && !videoIsPaused) {
+ showSkippedSegmentToast(otherSegment);
+ }
+ }
+ }
+ }
+
+ if (segmentToSkip.category == SegmentCategory.UNSUBMITTED) {
+ removeUnsubmittedSegments();
+ SponsorBlockUtils.setNewSponsorSegmentPreviewed();
+ } else if (!videoIsPaused) {
+ SponsorBlockUtils.sendViewRequestAsync(segmentToSkip);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "skipSegment failure", ex);
+ }
+ }
+
+ private static void showSkippedSegmentToast(@NonNull SponsorSegment segment) {
+ Utils.verifyOnMainThread();
+ toastNumberOfSegmentsSkipped++;
+ if (toastNumberOfSegmentsSkipped > 1) {
+ return; // toast already scheduled
+ }
+ toastSegmentSkipped = segment;
+
+ final long delayToToastMilliseconds = 250; // also the maximum time between skips to be considered skipping multiple segments
+ Utils.runOnMainThreadDelayed(() -> {
+ try {
+ if (toastSegmentSkipped == null) { // video was changed just after skipping segment
+ Logger.printDebug(() -> "Ignoring old scheduled show toast");
+ return;
+ }
+ Utils.showToastShort(toastNumberOfSegmentsSkipped == 1
+ ? toastSegmentSkipped.getSkippedToastText()
+ : str("revanced_sb_skipped_multiple_segments"));
+ } catch (Exception ex) {
+ Logger.printException(() -> "showSkippedSegmentToast failure", ex);
+ } finally {
+ toastNumberOfSegmentsSkipped = 0;
+ toastSegmentSkipped = null;
+ }
+ }, delayToToastMilliseconds);
+ }
+
+ /**
+ * @param segment can be either a highlight or a regular manual skip segment.
+ */
+ public static void onSkipSegmentClicked(@NonNull SponsorSegment segment) {
+ try {
+ if (segment != highlightSegment && segment != segmentCurrentlyPlaying) {
+ Logger.printException(() -> "error: segment not available to skip"); // should never happen
+ SponsorBlockViewController.hideSkipSegmentButton();
+ SponsorBlockViewController.hideSkipHighlightButton();
+ return;
+ }
+ skipSegment(segment, true);
+ } catch (Exception ex) {
+ Logger.printException(() -> "onSkipSegmentClicked failure", ex);
+ }
+ }
+
+ /**
+ * Injection point
+ */
+ public static void setSponsorBarRect(final Object self) {
+ try {
+ Field field = self.getClass().getDeclaredField("replaceMeWithsetSponsorBarRect");
+ field.setAccessible(true);
+ Rect rect = (Rect) Objects.requireNonNull(field.get(self));
+ setSponsorBarAbsoluteLeft(rect);
+ setSponsorBarAbsoluteRight(rect);
+ } catch (Exception ex) {
+ Logger.printException(() -> "setSponsorBarRect failure", ex);
+ }
+ }
+
+ private static void setSponsorBarAbsoluteLeft(Rect rect) {
+ final int left = rect.left;
+ if (sponsorBarAbsoluteLeft != left) {
+ sponsorBarAbsoluteLeft = left;
+ }
+ }
+
+ private static void setSponsorBarAbsoluteRight(Rect rect) {
+ final int right = rect.right;
+ if (sponsorAbsoluteBarRight != right) {
+ sponsorAbsoluteBarRight = right;
+ }
+ }
+
+ /**
+ * Injection point
+ */
+ public static void setSponsorBarThickness(int thickness) {
+ if (sponsorBarThickness != thickness) {
+ sponsorBarThickness = thickness;
+ }
+ }
+
+ /**
+ * Injection point.
+ */
+ public static String appendTimeWithoutSegments(String totalTime) {
+ try {
+ if (Settings.SB_ENABLED.get() && Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.get()
+ && !TextUtils.isEmpty(totalTime) && !TextUtils.isEmpty(timeWithoutSegments)) {
+ // Force LTR layout, to match the same LTR video time/length layout YouTube uses for all languages
+ return "\u202D" + totalTime + timeWithoutSegments; // u202D = left to right override
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "appendTimeWithoutSegments failure", ex);
+ }
+
+ return totalTime;
+ }
+
+ @SuppressLint("DefaultLocale")
+ private static void calculateTimeWithoutSegments() {
+ if (!Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.get() || videoLength <= 0
+ || segments == null || segments.length == 0) {
+ timeWithoutSegments = null;
+ return;
+ }
+
+ boolean foundNonhighlightSegments = false;
+ long timeWithoutSegmentsValue = videoLength;
+
+ for (int i = 0, length = segments.length; i < length; i++) {
+ SponsorSegment segment = segments[i];
+ if (segment.category == SegmentCategory.HIGHLIGHT) {
+ continue;
+ }
+ foundNonhighlightSegments = true;
+ long start = segment.start;
+ final long end = segment.end;
+ // To prevent nested segments from incorrectly counting additional time,
+ // check if the segment overlaps any earlier segments.
+ for (int j = 0; j < i; j++) {
+ start = Math.max(start, segments[j].end);
+ }
+ if (start < end) {
+ timeWithoutSegmentsValue -= (end - start);
+ }
+ }
+
+ if (!foundNonhighlightSegments) {
+ timeWithoutSegments = null;
+ return;
+ }
+
+ final long hours = timeWithoutSegmentsValue / 3600000;
+ final long minutes = (timeWithoutSegmentsValue / 60000) % 60;
+ final long seconds = (timeWithoutSegmentsValue / 1000) % 60;
+ if (hours > 0) {
+ timeWithoutSegments = String.format(Locale.ENGLISH, "\u2009(%d:%02d:%02d)", hours, minutes, seconds);
+ } else {
+ timeWithoutSegments = String.format(Locale.ENGLISH, "\u2009(%d:%02d)", minutes, seconds);
+ }
+ }
+
+ private static int getHighlightSegmentTimeBarScreenWidth() {
+ if (highlightSegmentTimeBarScreenWidth == -1) {
+ highlightSegmentTimeBarScreenWidth = (int) TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP, HIGHLIGHT_SEGMENT_DRAW_BAR_WIDTH,
+ Objects.requireNonNull(Utils.getContext()).getResources().getDisplayMetrics());
+ }
+ return highlightSegmentTimeBarScreenWidth;
+ }
+
+ /**
+ * Injection point.
+ */
+ public static void drawSponsorTimeBars(final Canvas canvas, final float posY) {
+ try {
+ if (segments == null) return;
+ if (videoLength <= 0) return;
+
+ final int thicknessDiv2 = sponsorBarThickness / 2; // rounds down
+ final float top = posY - (sponsorBarThickness - thicknessDiv2);
+ final float bottom = posY + thicknessDiv2;
+ final float videoMillisecondsToPixels = (1f / videoLength) * (sponsorAbsoluteBarRight - sponsorBarAbsoluteLeft);
+ final float leftPadding = sponsorBarAbsoluteLeft;
+
+ for (SponsorSegment segment : segments) {
+ final float left = leftPadding + segment.start * videoMillisecondsToPixels;
+ final float right;
+ if (segment.category == SegmentCategory.HIGHLIGHT) {
+ right = left + getHighlightSegmentTimeBarScreenWidth();
+ } else {
+ right = leftPadding + segment.end * videoMillisecondsToPixels;
+ }
+ canvas.drawRect(left, top, right, bottom, segment.category.paint);
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "drawSponsorTimeBars failure", ex);
+ }
+ }
+
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java
new file mode 100644
index 0000000000..c8d6e171b1
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockSettings.java
@@ -0,0 +1,246 @@
+package app.revanced.extension.youtube.sponsorblock;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.util.Patterns;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+
+import java.util.UUID;
+
+import app.revanced.extension.shared.settings.Setting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.settings.preference.SponsorBlockSettingsPreference;
+import app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour;
+import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory;
+
+public class SponsorBlockSettings {
+ /**
+ * Minimum length a SB user id must be, as set by SB API.
+ */
+ private static final int SB_PRIVATE_USER_ID_MINIMUM_LENGTH = 30;
+
+ public static void importDesktopSettings(@NonNull String json) {
+ Utils.verifyOnMainThread();
+ try {
+ JSONObject settingsJson = new JSONObject(json);
+ JSONObject barTypesObject = settingsJson.getJSONObject("barTypes");
+ JSONArray categorySelectionsArray = settingsJson.getJSONArray("categorySelections");
+
+ for (SegmentCategory category : SegmentCategory.categoriesWithoutUnsubmitted()) {
+ // clear existing behavior, as browser plugin exports no behavior for ignored categories
+ category.setBehaviour(CategoryBehaviour.IGNORE);
+ if (barTypesObject.has(category.keyValue)) {
+ JSONObject categoryObject = barTypesObject.getJSONObject(category.keyValue);
+ category.setColor(categoryObject.getString("color"));
+ }
+ }
+
+ for (int i = 0; i < categorySelectionsArray.length(); i++) {
+ JSONObject categorySelectionObject = categorySelectionsArray.getJSONObject(i);
+
+ String categoryKey = categorySelectionObject.getString("name");
+ SegmentCategory category = SegmentCategory.byCategoryKey(categoryKey);
+ if (category == null) {
+ continue; // unsupported category, ignore
+ }
+
+ final int desktopValue = categorySelectionObject.getInt("option");
+ CategoryBehaviour behaviour = CategoryBehaviour.byDesktopKeyValue(desktopValue);
+ if (behaviour == null) {
+ Utils.showToastLong(categoryKey + " unknown behavior key: " + categoryKey);
+ } else if (category == SegmentCategory.HIGHLIGHT && behaviour == CategoryBehaviour.SKIP_AUTOMATICALLY_ONCE) {
+ Utils.showToastLong("Skip-once behavior not allowed for " + category.keyValue);
+ category.setBehaviour(CategoryBehaviour.SKIP_AUTOMATICALLY); // use closest match
+ } else {
+ category.setBehaviour(behaviour);
+ }
+ }
+ SegmentCategory.updateEnabledCategories();
+
+ if (settingsJson.has("userID")) {
+ // User id does not exist if user never voted or created any segments.
+ String userID = settingsJson.getString("userID");
+ if (isValidSBUserId(userID)) {
+ Settings.SB_PRIVATE_USER_ID.save(userID);
+ }
+ }
+ Settings.SB_USER_IS_VIP.save(settingsJson.getBoolean("isVip"));
+ Settings.SB_TOAST_ON_SKIP.save(!settingsJson.getBoolean("dontShowNotice"));
+ Settings.SB_TRACK_SKIP_COUNT.save(settingsJson.getBoolean("trackViewCount"));
+ Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.save(settingsJson.getBoolean("showTimeWithSkips"));
+
+ String serverAddress = settingsJson.getString("serverAddress");
+ if (isValidSBServerAddress(serverAddress)) { // Old versions of ReVanced exported wrong url format
+ Settings.SB_API_URL.save(serverAddress);
+ }
+
+ final float minDuration = (float) settingsJson.getDouble("minDuration");
+ if (minDuration < 0) {
+ throw new IllegalArgumentException("invalid minDuration: " + minDuration);
+ }
+ Settings.SB_SEGMENT_MIN_DURATION.save(minDuration);
+
+ if (settingsJson.has("skipCount")) { // Value not exported in old versions of ReVanced
+ int skipCount = settingsJson.getInt("skipCount");
+ if (skipCount < 0) {
+ throw new IllegalArgumentException("invalid skipCount: " + skipCount);
+ }
+ Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.save(skipCount);
+ }
+
+ if (settingsJson.has("minutesSaved")) {
+ final double minutesSaved = settingsJson.getDouble("minutesSaved");
+ if (minutesSaved < 0) {
+ throw new IllegalArgumentException("invalid minutesSaved: " + minutesSaved);
+ }
+ Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.save((long) (minutesSaved * 60 * 1000));
+ }
+
+ Utils.showToastLong(str("revanced_sb_settings_import_successful"));
+ } catch (Exception ex) {
+ Logger.printInfo(() -> "failed to import settings", ex); // use info level, as we are showing our own toast
+ Utils.showToastLong(str("revanced_sb_settings_import_failed", ex.getMessage()));
+ }
+ }
+
+ @NonNull
+ public static String exportDesktopSettings() {
+ Utils.verifyOnMainThread();
+ try {
+ Logger.printDebug(() -> "Creating SponsorBlock export settings string");
+ JSONObject json = new JSONObject();
+
+ JSONObject barTypesObject = new JSONObject(); // categories' colors
+ JSONArray categorySelectionsArray = new JSONArray(); // categories' behavior
+
+ SegmentCategory[] categories = SegmentCategory.categoriesWithoutUnsubmitted();
+ for (SegmentCategory category : categories) {
+ JSONObject categoryObject = new JSONObject();
+ String categoryKey = category.keyValue;
+ categoryObject.put("color", category.colorString());
+ barTypesObject.put(categoryKey, categoryObject);
+
+ if (category.behaviour != CategoryBehaviour.IGNORE) {
+ JSONObject behaviorObject = new JSONObject();
+ behaviorObject.put("name", categoryKey);
+ behaviorObject.put("option", category.behaviour.desktopKeyValue);
+ categorySelectionsArray.put(behaviorObject);
+ }
+ }
+ if (SponsorBlockSettings.userHasSBPrivateId()) {
+ json.put("userID", Settings.SB_PRIVATE_USER_ID.get());
+ }
+ json.put("isVip", Settings.SB_USER_IS_VIP.get());
+ json.put("serverAddress", Settings.SB_API_URL.get());
+ json.put("dontShowNotice", !Settings.SB_TOAST_ON_SKIP.get());
+ json.put("showTimeWithSkips", Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.get());
+ json.put("minDuration", Settings.SB_SEGMENT_MIN_DURATION.get());
+ json.put("trackViewCount", Settings.SB_TRACK_SKIP_COUNT.get());
+ json.put("skipCount", Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.get());
+ json.put("minutesSaved", Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.get() / (60f * 1000));
+
+ json.put("categorySelections", categorySelectionsArray);
+ json.put("barTypes", barTypesObject);
+
+ return json.toString(2);
+ } catch (Exception ex) {
+ Logger.printInfo(() -> "failed to export settings", ex); // use info level, as we are showing our own toast
+ Utils.showToastLong(str("revanced_sb_settings_export_failed", ex));
+ return "";
+ }
+ }
+
+ /**
+ * Export the categories using flatten json (no embedded dictionaries or arrays).
+ */
+ public static void showExportWarningIfNeeded(@Nullable Context dialogContext) {
+ Utils.verifyOnMainThread();
+ initialize();
+
+ // If user has a SponsorBlock user id then show a warning.
+ if (dialogContext != null && SponsorBlockSettings.userHasSBPrivateId()
+ && !Settings.SB_HIDE_EXPORT_WARNING.get()) {
+ new AlertDialog.Builder(dialogContext)
+ .setMessage(str("revanced_sb_settings_revanced_export_user_id_warning"))
+ .setNeutralButton(str("revanced_sb_settings_revanced_export_user_id_warning_dismiss"),
+ (dialog, which) -> Settings.SB_HIDE_EXPORT_WARNING.save(true))
+ .setPositiveButton(android.R.string.ok, null)
+ .setCancelable(false)
+ .show();
+ }
+ }
+
+ public static boolean isValidSBUserId(@NonNull String userId) {
+ return !userId.isEmpty() && userId.length() >= SB_PRIVATE_USER_ID_MINIMUM_LENGTH;
+ }
+
+ /**
+ * A non comprehensive check if a SB api server address is valid.
+ */
+ public static boolean isValidSBServerAddress(@NonNull String serverAddress) {
+ if (!Patterns.WEB_URL.matcher(serverAddress).matches()) {
+ return false;
+ }
+ // Verify url is only the server address and does not contain a path such as: "https://sponsor.ajay.app/api/"
+ // Could use Patterns.compile, but this is simpler
+ final int lastDotIndex = serverAddress.lastIndexOf('.');
+ if (lastDotIndex != -1 && serverAddress.substring(lastDotIndex).contains("/")) {
+ return false;
+ }
+ // Optionally, could also verify the domain exists using "InetAddress.getByName(serverAddress)"
+ // but that should not be done on the main thread.
+ // Instead, assume the domain exists and the user knows what they're doing.
+ return true;
+ }
+
+ /**
+ * @return if the user has ever voted, created a segment, or imported existing SB settings.
+ */
+ public static boolean userHasSBPrivateId() {
+ return !Settings.SB_PRIVATE_USER_ID.get().isEmpty();
+ }
+
+ /**
+ * Use this only if a user id is required (creating segments, voting).
+ */
+ @NonNull
+ public static String getSBPrivateUserID() {
+ String uuid = Settings.SB_PRIVATE_USER_ID.get();
+ if (uuid.isEmpty()) {
+ uuid = (UUID.randomUUID().toString() +
+ UUID.randomUUID().toString() +
+ UUID.randomUUID().toString())
+ .replace("-", "");
+ Settings.SB_PRIVATE_USER_ID.save(uuid);
+ }
+ return uuid;
+ }
+
+ private static boolean initialized;
+
+ public static void initialize() {
+ if (initialized) {
+ return;
+ }
+ initialized = true;
+
+ SegmentCategory.updateEnabledCategories();
+ }
+
+ /**
+ * Updates internal data based on {@link Setting} values.
+ */
+ public static void updateFromImportedSettings() {
+ SegmentCategory.loadAllCategoriesFromSettings();
+ SponsorBlockSettingsPreference.updateSegmentCategories();
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockUtils.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockUtils.java
new file mode 100644
index 0000000000..88d128e7e2
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/SponsorBlockUtils.java
@@ -0,0 +1,495 @@
+package app.revanced.extension.youtube.sponsorblock;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import android.annotation.TargetApi;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.text.Html;
+import android.widget.EditText;
+
+import androidx.annotation.NonNull;
+
+import java.lang.ref.WeakReference;
+import java.text.NumberFormat;
+import java.time.Duration;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.VideoInformation;
+import app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour;
+import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory;
+import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment;
+import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment.SegmentVote;
+import app.revanced.extension.youtube.sponsorblock.requests.SBRequester;
+import app.revanced.extension.youtube.sponsorblock.ui.SponsorBlockViewController;
+
+/**
+ * Not thread safe. All fields/methods must be accessed from the main thread.
+ *
+ * @noinspection deprecation
+ */
+public class SponsorBlockUtils {
+ private static final String LOCKED_COLOR = "#FFC83D";
+
+ private static final String MANUAL_EDIT_TIME_TEXT_HINT = "hh:mm:ss.sss";
+ private static final Pattern manualEditTimePattern
+ = Pattern.compile("((\\d{1,2}):)?(\\d{1,2}):(\\d{2})(\\.(\\d{1,3}))?");
+ private static final NumberFormat statsNumberFormatter = NumberFormat.getNumberInstance();
+
+ private static long newSponsorSegmentDialogShownMillis;
+ private static long newSponsorSegmentStartMillis = -1;
+ private static long newSponsorSegmentEndMillis = -1;
+ private static boolean newSponsorSegmentPreviewed;
+ private static final DialogInterface.OnClickListener newSponsorSegmentDialogListener = new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ switch (which) {
+ // start
+ case DialogInterface.BUTTON_NEGATIVE ->
+ newSponsorSegmentStartMillis = newSponsorSegmentDialogShownMillis;
+ // end
+ case DialogInterface.BUTTON_POSITIVE ->
+ newSponsorSegmentEndMillis = newSponsorSegmentDialogShownMillis;
+ }
+ dialog.dismiss();
+ }
+ };
+ private static SegmentCategory newUserCreatedSegmentCategory;
+ private static final DialogInterface.OnClickListener segmentTypeListener = new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ try {
+ SegmentCategory category = SegmentCategory.categoriesWithoutHighlights()[which];
+ final boolean enableButton;
+ if (category.behaviour == CategoryBehaviour.IGNORE) {
+ Utils.showToastLong(str("revanced_sb_new_segment_disabled_category"));
+ enableButton = false;
+ } else {
+ newUserCreatedSegmentCategory = category;
+ enableButton = true;
+ }
+
+ ((AlertDialog) dialog)
+ .getButton(DialogInterface.BUTTON_POSITIVE)
+ .setEnabled(enableButton);
+ } catch (Exception ex) {
+ Logger.printException(() -> "segmentTypeListener failure", ex);
+ }
+ }
+ };
+ private static final DialogInterface.OnClickListener segmentReadyDialogButtonListener = new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ try {
+ SponsorBlockViewController.hideNewSegmentLayout();
+ Context context = ((AlertDialog) dialog).getContext();
+ dialog.dismiss();
+
+ SegmentCategory[] categories = SegmentCategory.categoriesWithoutHighlights();
+ CharSequence[] titles = new CharSequence[categories.length];
+ for (int i = 0, length = categories.length; i < length; i++) {
+ titles[i] = categories[i].getTitleWithColorDot();
+ }
+
+ newUserCreatedSegmentCategory = null;
+ new AlertDialog.Builder(context)
+ .setTitle(str("revanced_sb_new_segment_choose_category"))
+ .setSingleChoiceItems(titles, -1, segmentTypeListener)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(android.R.string.ok, segmentCategorySelectedDialogListener)
+ .show()
+ .getButton(DialogInterface.BUTTON_POSITIVE)
+ .setEnabled(false);
+ } catch (Exception ex) {
+ Logger.printException(() -> "segmentReadyDialogButtonListener failure", ex);
+ }
+ }
+ };
+ private static final DialogInterface.OnClickListener segmentCategorySelectedDialogListener = (dialog, which) -> {
+ dialog.dismiss();
+ submitNewSegment();
+ };
+ private static final EditByHandSaveDialogListener editByHandSaveDialogListener = new EditByHandSaveDialogListener();
+ private static final DialogInterface.OnClickListener editByHandDialogListener = (dialog, which) -> {
+ try {
+ Context context = ((AlertDialog) dialog).getContext();
+
+ final boolean isStart = DialogInterface.BUTTON_NEGATIVE == which;
+
+ final EditText textView = new EditText(context);
+ textView.setHint(MANUAL_EDIT_TIME_TEXT_HINT);
+ if (isStart) {
+ if (newSponsorSegmentStartMillis >= 0)
+ textView.setText(formatSegmentTime(newSponsorSegmentStartMillis));
+ } else {
+ if (newSponsorSegmentEndMillis >= 0)
+ textView.setText(formatSegmentTime(newSponsorSegmentEndMillis));
+ }
+
+ editByHandSaveDialogListener.settingStart = isStart;
+ editByHandSaveDialogListener.editTextRef = new WeakReference<>(textView);
+ new AlertDialog.Builder(context)
+ .setTitle(str(isStart ? "revanced_sb_new_segment_time_start" : "revanced_sb_new_segment_time_end"))
+ .setView(textView)
+ .setNegativeButton(android.R.string.cancel, null)
+ .setNeutralButton(str("revanced_sb_new_segment_now"), editByHandSaveDialogListener)
+ .setPositiveButton(android.R.string.ok, editByHandSaveDialogListener)
+ .show();
+
+ dialog.dismiss();
+ } catch (Exception ex) {
+ Logger.printException(() -> "editByHandDialogListener failure", ex);
+ }
+ };
+ private static final DialogInterface.OnClickListener segmentVoteClickListener = (dialog, which) -> {
+ try {
+ final Context context = ((AlertDialog) dialog).getContext();
+ SponsorSegment[] segments = SegmentPlaybackController.getSegments();
+ if (segments == null || segments.length == 0) {
+ // should never be reached
+ Logger.printException(() -> "Segment is no longer available on the client");
+ return;
+ }
+ SponsorSegment segment = segments[which];
+
+ SegmentVote[] voteOptions = (segment.category == SegmentCategory.HIGHLIGHT)
+ ? SegmentVote.voteTypesWithoutCategoryChange // highlight segments cannot change category
+ : SegmentVote.values();
+ CharSequence[] items = new CharSequence[voteOptions.length];
+
+ for (int i = 0; i < voteOptions.length; i++) {
+ SegmentVote voteOption = voteOptions[i];
+ String title = voteOption.title.toString();
+ if (Settings.SB_USER_IS_VIP.get() && segment.isLocked && voteOption.shouldHighlight) {
+ items[i] = Html.fromHtml(String.format("%s ", LOCKED_COLOR, title));
+ } else {
+ items[i] = title;
+ }
+ }
+
+ new AlertDialog.Builder(context)
+ .setItems(items, (dialog1, which1) -> {
+ SegmentVote voteOption = voteOptions[which1];
+ switch (voteOption) {
+ case UPVOTE, DOWNVOTE ->
+ SBRequester.voteForSegmentOnBackgroundThread(segment, voteOption);
+ case CATEGORY_CHANGE -> onNewCategorySelect(segment, context);
+ }
+ })
+ .show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "segmentVoteClickListener failure", ex);
+ }
+ };
+
+ private SponsorBlockUtils() {
+ }
+
+ static void setNewSponsorSegmentPreviewed() {
+ newSponsorSegmentPreviewed = true;
+ }
+
+ static void clearUnsubmittedSegmentTimes() {
+ newSponsorSegmentDialogShownMillis = 0;
+ newSponsorSegmentEndMillis = newSponsorSegmentStartMillis = -1;
+ newSponsorSegmentPreviewed = false;
+ }
+
+ private static void submitNewSegment() {
+ try {
+ Utils.verifyOnMainThread();
+ final long start = newSponsorSegmentStartMillis;
+ final long end = newSponsorSegmentEndMillis;
+ final String videoId = SegmentPlaybackController.getVideoId();
+ final long videoLength = SegmentPlaybackController.getVideoLength();
+ final SegmentCategory segmentCategory = newUserCreatedSegmentCategory;
+ if (start < 0 || end < 0 || start >= end || videoLength <= 0 || videoId.isEmpty() || segmentCategory == null) {
+ Logger.printException(() -> "invalid parameters");
+ return;
+ }
+ clearUnsubmittedSegmentTimes();
+ Utils.runOnBackgroundThread(() -> {
+ SBRequester.submitSegments(videoId, segmentCategory.keyValue, start, end, videoLength);
+ SegmentPlaybackController.executeDownloadSegments(videoId);
+ });
+ } catch (Exception e) {
+ Logger.printException(() -> "Unable to submit segment", e);
+ }
+ }
+
+ public static void onMarkLocationClicked() {
+ try {
+ Utils.verifyOnMainThread();
+ newSponsorSegmentDialogShownMillis = VideoInformation.getVideoTime();
+
+ new AlertDialog.Builder(SponsorBlockViewController.getOverLaysViewGroupContext())
+ .setTitle(str("revanced_sb_new_segment_title"))
+ .setMessage(str("revanced_sb_new_segment_mark_current_time_as_question",
+ formatSegmentTime(newSponsorSegmentDialogShownMillis)))
+ .setNeutralButton(android.R.string.cancel, null)
+ .setNegativeButton(str("revanced_sb_new_segment_mark_start"), newSponsorSegmentDialogListener)
+ .setPositiveButton(str("revanced_sb_new_segment_mark_end"), newSponsorSegmentDialogListener)
+ .show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "onMarkLocationClicked failure", ex);
+ }
+ }
+
+ public static void onPublishClicked() {
+ try {
+ Utils.verifyOnMainThread();
+ if (newSponsorSegmentStartMillis < 0 || newSponsorSegmentEndMillis < 0) {
+ Utils.showToastShort(str("revanced_sb_new_segment_mark_locations_first"));
+ } else if (newSponsorSegmentStartMillis >= newSponsorSegmentEndMillis) {
+ Utils.showToastShort(str("revanced_sb_new_segment_start_is_before_end"));
+ } else if (!newSponsorSegmentPreviewed && newSponsorSegmentStartMillis != 0) {
+ Utils.showToastLong(str("revanced_sb_new_segment_preview_segment_first"));
+ } else {
+ final long segmentLength = (newSponsorSegmentEndMillis - newSponsorSegmentStartMillis) / 1000;
+ new AlertDialog.Builder(SponsorBlockViewController.getOverLaysViewGroupContext())
+ .setTitle(str("revanced_sb_new_segment_confirm_title"))
+ .setMessage(str("revanced_sb_new_segment_confirm_contents",
+ formatSegmentTime(newSponsorSegmentStartMillis),
+ formatSegmentTime(newSponsorSegmentEndMillis),
+ getTimeSavedString(segmentLength)))
+ .setNegativeButton(android.R.string.no, null)
+ .setPositiveButton(android.R.string.yes, segmentReadyDialogButtonListener)
+ .show();
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onPublishClicked failure", ex);
+ }
+ }
+
+ public static void onVotingClicked(@NonNull Context context) {
+ try {
+ Utils.verifyOnMainThread();
+ SponsorSegment[] segments = SegmentPlaybackController.getSegments();
+ if (segments == null || segments.length == 0) {
+ // Button is hidden if no segments exist.
+ // But if prior video had segments, and current video does not,
+ // then the button persists until the overlay fades out (this is intentional, as abruptly hiding the button is jarring).
+ Utils.showToastShort(str("revanced_sb_vote_no_segments"));
+ return;
+ }
+
+ final int numberOfSegments = segments.length;
+ CharSequence[] titles = new CharSequence[numberOfSegments];
+ for (int i = 0; i < numberOfSegments; i++) {
+ SponsorSegment segment = segments[i];
+ if (segment.category == SegmentCategory.UNSUBMITTED) {
+ continue;
+ }
+ StringBuilder htmlBuilder = new StringBuilder();
+ htmlBuilder.append(String.format("⬤ %s ",
+ segment.category.color, segment.category.title));
+ htmlBuilder.append(formatSegmentTime(segment.start));
+ if (segment.category != SegmentCategory.HIGHLIGHT) {
+ htmlBuilder.append(" to ").append(formatSegmentTime(segment.end));
+ }
+ htmlBuilder.append(" ");
+ if (i + 1 != numberOfSegments) // prevents trailing new line after last segment
+ htmlBuilder.append(" ");
+ titles[i] = Html.fromHtml(htmlBuilder.toString());
+ }
+
+ new AlertDialog.Builder(context)
+ .setItems(titles, segmentVoteClickListener)
+ .show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "onVotingClicked failure", ex);
+ }
+ }
+
+ private static void onNewCategorySelect(@NonNull SponsorSegment segment, @NonNull Context context) {
+ try {
+ Utils.verifyOnMainThread();
+ final SegmentCategory[] values = SegmentCategory.categoriesWithoutHighlights();
+ CharSequence[] titles = new CharSequence[values.length];
+ for (int i = 0; i < values.length; i++) {
+ titles[i] = values[i].getTitleWithColorDot();
+ }
+
+ new AlertDialog.Builder(context)
+ .setTitle(str("revanced_sb_new_segment_choose_category"))
+ .setItems(titles, (dialog, which) -> SBRequester.voteToChangeCategoryOnBackgroundThread(segment, values[which]))
+ .show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "onNewCategorySelect failure", ex);
+ }
+ }
+
+ public static void onPreviewClicked() {
+ try {
+ Utils.verifyOnMainThread();
+ if (newSponsorSegmentStartMillis < 0 || newSponsorSegmentEndMillis < 0) {
+ Utils.showToastShort(str("revanced_sb_new_segment_mark_locations_first"));
+ } else if (newSponsorSegmentStartMillis >= newSponsorSegmentEndMillis) {
+ Utils.showToastShort(str("revanced_sb_new_segment_start_is_before_end"));
+ } else {
+ SegmentPlaybackController.removeUnsubmittedSegments(); // If user hits preview more than once before playing.
+ SegmentPlaybackController.addUnsubmittedSegment(
+ new SponsorSegment(SegmentCategory.UNSUBMITTED, null,
+ newSponsorSegmentStartMillis, newSponsorSegmentEndMillis, false));
+ VideoInformation.seekTo(newSponsorSegmentStartMillis - 2000, SegmentPlaybackController.getVideoLength());
+ }
+ } catch (Exception ex) {
+ Logger.printException(() -> "onPreviewClicked failure", ex);
+ }
+ }
+
+
+ static void sendViewRequestAsync(@NonNull SponsorSegment segment) {
+ if (segment.recordedAsSkipped || segment.category == SegmentCategory.UNSUBMITTED) {
+ return;
+ }
+ segment.recordedAsSkipped = true;
+ final long totalTimeSkipped = Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.get() + segment.length();
+ Settings.SB_LOCAL_TIME_SAVED_MILLISECONDS.save(totalTimeSkipped);
+ Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.save(Settings.SB_LOCAL_TIME_SAVED_NUMBER_SEGMENTS.get() + 1);
+
+ if (Settings.SB_TRACK_SKIP_COUNT.get()) {
+ Utils.runOnBackgroundThread(() -> SBRequester.sendSegmentSkippedViewedRequest(segment));
+ }
+ }
+
+ public static void onEditByHandClicked() {
+ try {
+ Utils.verifyOnMainThread();
+ new AlertDialog.Builder(SponsorBlockViewController.getOverLaysViewGroupContext())
+ .setTitle(str("revanced_sb_new_segment_edit_by_hand_title"))
+ .setMessage(str("revanced_sb_new_segment_edit_by_hand_content"))
+ .setNeutralButton(android.R.string.cancel, null)
+ .setNegativeButton(str("revanced_sb_new_segment_mark_start"), editByHandDialogListener)
+ .setPositiveButton(str("revanced_sb_new_segment_mark_end"), editByHandDialogListener)
+ .show();
+ } catch (Exception ex) {
+ Logger.printException(() -> "onEditByHandClicked failure", ex);
+ }
+ }
+
+ public static String getNumberOfSkipsString(int viewCount) {
+ return statsNumberFormatter.format(viewCount);
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ private static long parseSegmentTime(@NonNull String time) {
+ Matcher matcher = manualEditTimePattern.matcher(time);
+ if (!matcher.matches()) {
+ return -1;
+ }
+ String hoursStr = matcher.group(2); // Hours is optional.
+ String minutesStr = matcher.group(3);
+ String secondsStr = matcher.group(4);
+ String millisecondsStr = matcher.group(6); // Milliseconds is optional.
+
+ try {
+ final int hours = (hoursStr != null) ? Integer.parseInt(hoursStr) : 0;
+ final int minutes = Integer.parseInt(minutesStr);
+ final int seconds = Integer.parseInt(secondsStr);
+ final int milliseconds;
+ if (millisecondsStr != null) {
+ // Pad out with zeros if not all decimal places were used.
+ millisecondsStr = String.format(Locale.US, "%-3s", millisecondsStr).replace(' ', '0');
+ milliseconds = Integer.parseInt(millisecondsStr);
+ } else {
+ milliseconds = 0;
+ }
+
+ return (hours * 3600000L) + (minutes * 60000L) + (seconds * 1000L) + milliseconds;
+ } catch (NumberFormatException ex) {
+ Logger.printInfo(() -> "Time format exception: " + time, ex);
+ return -1;
+ }
+ }
+
+ private static String formatSegmentTime(long segmentTime) {
+ // Use same time formatting as shown in the video player.
+ final long videoLength = SegmentPlaybackController.getVideoLength();
+
+ // Cannot use DateFormatter, as videos over 24 hours will rollover and not display correctly.
+ final long hours = TimeUnit.MILLISECONDS.toHours(segmentTime);
+ final long minutes = TimeUnit.MILLISECONDS.toMinutes(segmentTime) % 60;
+ final long seconds = TimeUnit.MILLISECONDS.toSeconds(segmentTime) % 60;
+ final long milliseconds = segmentTime % 1000;
+
+ final String formatPattern;
+ Object[] formatArgs = {minutes, seconds, milliseconds};
+
+ if (videoLength < (10 * 60 * 1000)) {
+ formatPattern = "%01d:%02d.%03d"; // Less than 10 minutes.
+ } else if (videoLength < (60 * 60 * 1000)) {
+ formatPattern = "%02d:%02d.%03d"; // Less than 1 hour.
+ } else if (videoLength < (10 * 60 * 60 * 1000)) {
+ formatPattern = "%01d:%02d:%02d.%03d"; // Less than 10 hours.
+ formatArgs = new Object[]{hours, minutes, seconds, milliseconds};
+ } else {
+ formatPattern = "%02d:%02d:%02d.%03d"; // Why is this on YouTube?
+ formatArgs = new Object[]{hours, minutes, seconds, milliseconds};
+ }
+
+ return String.format(Locale.US, formatPattern, formatArgs);
+ }
+
+ @TargetApi(26)
+ public static String getTimeSavedString(long totalSecondsSaved) {
+ Duration duration = Duration.ofSeconds(totalSecondsSaved);
+ final long hours = duration.toHours();
+ final long minutes = duration.toMinutes() % 60;
+ // Format all numbers so non-western numbers use a consistent appearance.
+ String minutesFormatted = statsNumberFormatter.format(minutes);
+ if (hours > 0) {
+ String hoursFormatted = statsNumberFormatter.format(hours);
+ return str("revanced_sb_stats_saved_hour_format", hoursFormatted, minutesFormatted);
+ }
+ final long seconds = duration.getSeconds() % 60;
+ String secondsFormatted = statsNumberFormatter.format(seconds);
+ if (minutes > 0) {
+ return str("revanced_sb_stats_saved_minute_format", minutesFormatted, secondsFormatted);
+ }
+ return str("revanced_sb_stats_saved_second_format", secondsFormatted);
+ }
+
+ private static class EditByHandSaveDialogListener implements DialogInterface.OnClickListener {
+ boolean settingStart;
+ WeakReference editTextRef = new WeakReference<>(null);
+
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ try {
+ final EditText editText = editTextRef.get();
+ if (editText == null) return;
+
+ final long time;
+ if (which == DialogInterface.BUTTON_NEUTRAL) {
+ time = VideoInformation.getVideoTime();
+ } else {
+ time = parseSegmentTime(editText.getText().toString());
+ if (time < 0) {
+ Utils.showToastLong(str("revanced_sb_new_segment_edit_by_hand_parse_error"));
+ return;
+ }
+ }
+
+ if (settingStart)
+ newSponsorSegmentStartMillis = Math.max(time, 0);
+ else
+ newSponsorSegmentEndMillis = time;
+
+ if (which == DialogInterface.BUTTON_NEUTRAL)
+ editByHandDialogListener.onClick(dialog, settingStart ?
+ DialogInterface.BUTTON_NEGATIVE :
+ DialogInterface.BUTTON_POSITIVE);
+ } catch (Exception ex) {
+ Logger.printException(() -> "EditByHandSaveDialogListener failure", ex);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/CategoryBehaviour.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/CategoryBehaviour.java
new file mode 100644
index 0000000000..5e5b4e8f11
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/CategoryBehaviour.java
@@ -0,0 +1,124 @@
+package app.revanced.extension.youtube.sponsorblock.objects;
+
+import static app.revanced.extension.shared.utils.StringRef.sf;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Objects;
+
+import app.revanced.extension.shared.utils.StringRef;
+import app.revanced.extension.shared.utils.Utils;
+
+public enum CategoryBehaviour {
+ SKIP_AUTOMATICALLY("skip", 2, true, sf("revanced_sb_skip_automatically")),
+ // desktop does not have skip-once behavior. Key is unique to ReVanced
+ SKIP_AUTOMATICALLY_ONCE("skip-once", 3, true, sf("revanced_sb_skip_automatically_once")),
+ MANUAL_SKIP("manual-skip", 1, false, sf("revanced_sb_skip_showbutton")),
+ SHOW_IN_SEEKBAR("seekbar-only", 0, false, sf("revanced_sb_skip_seekbaronly")),
+ // ignored categories are not exported to json, and ignore is the default behavior when importing
+ IGNORE("ignore", -1, false, sf("revanced_sb_skip_ignore"));
+
+ /**
+ * ReVanced specific value.
+ */
+ @NonNull
+ public final String reVancedKeyValue;
+ /**
+ * Desktop specific value.
+ */
+ public final int desktopKeyValue;
+ /**
+ * If the segment should skip automatically
+ */
+ public final boolean skipAutomatically;
+ @NonNull
+ public final StringRef description;
+
+ CategoryBehaviour(String reVancedKeyValue, int desktopKeyValue, boolean skipAutomatically, StringRef description) {
+ this.reVancedKeyValue = Objects.requireNonNull(reVancedKeyValue);
+ this.desktopKeyValue = desktopKeyValue;
+ this.skipAutomatically = skipAutomatically;
+ this.description = Objects.requireNonNull(description);
+ }
+
+ @Nullable
+ public static CategoryBehaviour byReVancedKeyValue(@NonNull String keyValue) {
+ for (CategoryBehaviour behaviour : values()) {
+ if (behaviour.reVancedKeyValue.equals(keyValue)) {
+ return behaviour;
+ }
+ }
+ return null;
+ }
+
+ @Nullable
+ public static CategoryBehaviour byDesktopKeyValue(int desktopKeyValue) {
+ for (CategoryBehaviour behaviour : values()) {
+ if (behaviour.desktopKeyValue == desktopKeyValue) {
+ return behaviour;
+ }
+ }
+ return null;
+ }
+
+ private static String[] behaviorKeyValues;
+ private static String[] behaviorDescriptions;
+
+ private static String[] behaviorKeyValuesWithoutSkipOnce;
+ private static String[] behaviorDescriptionsWithoutSkipOnce;
+
+ private static void createNameAndKeyArrays() {
+ Utils.verifyOnMainThread();
+
+ CategoryBehaviour[] behaviours = values();
+ final int behaviorLength = behaviours.length;
+ behaviorKeyValues = new String[behaviorLength];
+ behaviorDescriptions = new String[behaviorLength];
+ behaviorKeyValuesWithoutSkipOnce = new String[behaviorLength - 1];
+ behaviorDescriptionsWithoutSkipOnce = new String[behaviorLength - 1];
+
+ int behaviorIndex = 0, behaviorHighlightIndex = 0;
+ while (behaviorIndex < behaviorLength) {
+ CategoryBehaviour behaviour = behaviours[behaviorIndex];
+ String value = behaviour.reVancedKeyValue;
+ String description = behaviour.description.toString();
+ behaviorKeyValues[behaviorIndex] = value;
+ behaviorDescriptions[behaviorIndex] = description;
+ behaviorIndex++;
+ if (behaviour != SKIP_AUTOMATICALLY_ONCE) {
+ behaviorKeyValuesWithoutSkipOnce[behaviorHighlightIndex] = value;
+ behaviorDescriptionsWithoutSkipOnce[behaviorHighlightIndex] = description;
+ behaviorHighlightIndex++;
+ }
+ }
+ }
+
+ public static String[] getBehaviorKeyValues() {
+ if (behaviorKeyValues == null) {
+ createNameAndKeyArrays();
+ }
+ return behaviorKeyValues;
+ }
+
+ public static String[] getBehaviorKeyValuesWithoutSkipOnce() {
+ if (behaviorKeyValuesWithoutSkipOnce == null) {
+ createNameAndKeyArrays();
+ }
+ return behaviorKeyValuesWithoutSkipOnce;
+ }
+
+ public static String[] getBehaviorDescriptions() {
+ if (behaviorDescriptions == null) {
+ createNameAndKeyArrays();
+ }
+ return behaviorDescriptions;
+ }
+
+ public static String[] getBehaviorDescriptionsWithoutSkipOnce() {
+ if (behaviorDescriptionsWithoutSkipOnce == null) {
+ createNameAndKeyArrays();
+ }
+ return behaviorDescriptionsWithoutSkipOnce;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java
new file mode 100644
index 0000000000..3d1e90f66d
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SegmentCategory.java
@@ -0,0 +1,351 @@
+package app.revanced.extension.youtube.sponsorblock.objects;
+
+import static app.revanced.extension.shared.utils.StringRef.sf;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_FILLER;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_FILLER_COLOR;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_HIGHLIGHT;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_HIGHLIGHT_COLOR;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_INTERACTION;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_INTERACTION_COLOR;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_INTRO;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_INTRO_COLOR;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_MUSIC_OFFTOPIC;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_MUSIC_OFFTOPIC_COLOR;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_OUTRO;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_OUTRO_COLOR;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_PREVIEW;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_PREVIEW_COLOR;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_SELF_PROMO;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_SELF_PROMO_COLOR;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_SPONSOR;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_SPONSOR_COLOR;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_UNSUBMITTED;
+import static app.revanced.extension.youtube.settings.Settings.SB_CATEGORY_UNSUBMITTED_COLOR;
+
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.text.Html;
+import android.text.Spanned;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import app.revanced.extension.shared.settings.StringSetting;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.StringRef;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+
+@SuppressWarnings({"deprecation", "StaticFieldLeak"})
+public enum SegmentCategory {
+ SPONSOR("sponsor", sf("revanced_sb_segments_sponsor"), sf("revanced_sb_skip_button_sponsor"), sf("revanced_sb_skipped_sponsor"),
+ SB_CATEGORY_SPONSOR, SB_CATEGORY_SPONSOR_COLOR),
+ SELF_PROMO("selfpromo", sf("revanced_sb_segments_selfpromo"), sf("revanced_sb_skip_button_selfpromo"), sf("revanced_sb_skipped_selfpromo"),
+ SB_CATEGORY_SELF_PROMO, SB_CATEGORY_SELF_PROMO_COLOR),
+ INTERACTION("interaction", sf("revanced_sb_segments_interaction"), sf("revanced_sb_skip_button_interaction"), sf("revanced_sb_skipped_interaction"),
+ SB_CATEGORY_INTERACTION, SB_CATEGORY_INTERACTION_COLOR),
+ /**
+ * Unique category that is treated differently than the rest.
+ */
+ HIGHLIGHT("poi_highlight", sf("revanced_sb_segments_highlight"), sf("revanced_sb_skip_button_highlight"), sf("revanced_sb_skipped_highlight"),
+ SB_CATEGORY_HIGHLIGHT, SB_CATEGORY_HIGHLIGHT_COLOR),
+ INTRO("intro", sf("revanced_sb_segments_intro"),
+ sf("revanced_sb_skip_button_intro_beginning"), sf("revanced_sb_skip_button_intro_middle"), sf("revanced_sb_skip_button_intro_end"),
+ sf("revanced_sb_skipped_intro_beginning"), sf("revanced_sb_skipped_intro_middle"), sf("revanced_sb_skipped_intro_end"),
+ SB_CATEGORY_INTRO, SB_CATEGORY_INTRO_COLOR),
+ OUTRO("outro", sf("revanced_sb_segments_outro"), sf("revanced_sb_skip_button_outro"), sf("revanced_sb_skipped_outro"),
+ SB_CATEGORY_OUTRO, SB_CATEGORY_OUTRO_COLOR),
+ PREVIEW("preview", sf("revanced_sb_segments_preview"),
+ sf("revanced_sb_skip_button_preview_beginning"), sf("revanced_sb_skip_button_preview_middle"), sf("revanced_sb_skip_button_preview_end"),
+ sf("revanced_sb_skipped_preview_beginning"), sf("revanced_sb_skipped_preview_middle"), sf("revanced_sb_skipped_preview_end"),
+ SB_CATEGORY_PREVIEW, SB_CATEGORY_PREVIEW_COLOR),
+ FILLER("filler", sf("revanced_sb_segments_filler"), sf("revanced_sb_skip_button_filler"), sf("revanced_sb_skipped_filler"),
+ SB_CATEGORY_FILLER, SB_CATEGORY_FILLER_COLOR),
+ MUSIC_OFFTOPIC("music_offtopic", sf("revanced_sb_segments_nomusic"), sf("revanced_sb_skip_button_nomusic"), sf("revanced_sb_skipped_nomusic"),
+ SB_CATEGORY_MUSIC_OFFTOPIC, SB_CATEGORY_MUSIC_OFFTOPIC_COLOR),
+ UNSUBMITTED("unsubmitted", StringRef.empty, sf("revanced_sb_skip_button_unsubmitted"), sf("revanced_sb_skipped_unsubmitted"),
+ SB_CATEGORY_UNSUBMITTED, SB_CATEGORY_UNSUBMITTED_COLOR),
+ ;
+
+ private static final StringRef skipSponsorTextCompact = sf("revanced_sb_skip_button_compact");
+ private static final StringRef skipSponsorTextCompactHighlight = sf("revanced_sb_skip_button_compact_highlight");
+
+ private static final SegmentCategory[] categoriesWithoutHighlights = new SegmentCategory[]{
+ SPONSOR,
+ SELF_PROMO,
+ INTERACTION,
+ INTRO,
+ OUTRO,
+ PREVIEW,
+ FILLER,
+ MUSIC_OFFTOPIC,
+ };
+
+ private static final SegmentCategory[] categoriesWithoutUnsubmitted = new SegmentCategory[]{
+ SPONSOR,
+ SELF_PROMO,
+ INTERACTION,
+ HIGHLIGHT,
+ INTRO,
+ OUTRO,
+ PREVIEW,
+ FILLER,
+ MUSIC_OFFTOPIC,
+ };
+ private static final Map mValuesMap = new HashMap<>(2 * categoriesWithoutUnsubmitted.length);
+
+ /**
+ * Categories currently enabled, formatted for an API call
+ */
+ public static String sponsorBlockAPIFetchCategories = "[]";
+
+ static {
+ for (SegmentCategory value : categoriesWithoutUnsubmitted)
+ mValuesMap.put(value.keyValue, value);
+ }
+
+ @NonNull
+ public static SegmentCategory[] categoriesWithoutUnsubmitted() {
+ return categoriesWithoutUnsubmitted;
+ }
+
+ @NonNull
+ public static SegmentCategory[] categoriesWithoutHighlights() {
+ return categoriesWithoutHighlights;
+ }
+
+ @Nullable
+ public static SegmentCategory byCategoryKey(@NonNull String key) {
+ return mValuesMap.get(key);
+ }
+
+ /**
+ * Must be called if behavior of any category is changed
+ */
+ public static void updateEnabledCategories() {
+ Utils.verifyOnMainThread();
+ Logger.printDebug(() -> "updateEnabledCategories");
+ SegmentCategory[] categories = categoriesWithoutUnsubmitted();
+ List enabledCategories = new ArrayList<>(categories.length);
+ for (SegmentCategory category : categories) {
+ if (category.behaviour != CategoryBehaviour.IGNORE) {
+ enabledCategories.add(category.keyValue);
+ }
+ }
+
+ //"[%22sponsor%22,%22outro%22,%22music_offtopic%22,%22intro%22,%22selfpromo%22,%22interaction%22,%22preview%22]";
+ if (enabledCategories.isEmpty())
+ sponsorBlockAPIFetchCategories = "[]";
+ else
+ sponsorBlockAPIFetchCategories = "[%22" + TextUtils.join("%22,%22", enabledCategories) + "%22]";
+ }
+
+ public static void loadAllCategoriesFromSettings() {
+ for (SegmentCategory category : values()) {
+ category.loadFromSettings();
+ }
+ updateEnabledCategories();
+ }
+
+ @NonNull
+ public final String keyValue;
+ @NonNull
+ public final StringSetting behaviorSetting;
+ @NonNull
+ private final StringSetting colorSetting;
+
+ @NonNull
+ public final StringRef title;
+
+ /**
+ * Skip button text, if the skip occurs in the first quarter of the video
+ */
+ @NonNull
+ public final StringRef skipButtonTextBeginning;
+ /**
+ * Skip button text, if the skip occurs in the middle half of the video
+ */
+ @NonNull
+ public final StringRef skipButtonTextMiddle;
+ /**
+ * Skip button text, if the skip occurs in the last quarter of the video
+ */
+ @NonNull
+ public final StringRef skipButtonTextEnd;
+ /**
+ * Skipped segment toast, if the skip occurred in the first quarter of the video
+ */
+ @NonNull
+ public final StringRef skippedToastBeginning;
+ /**
+ * Skipped segment toast, if the skip occurred in the middle half of the video
+ */
+ @NonNull
+ public final StringRef skippedToastMiddle;
+ /**
+ * Skipped segment toast, if the skip occurred in the last quarter of the video
+ */
+ @NonNull
+ public final StringRef skippedToastEnd;
+
+ @NonNull
+ public final Paint paint;
+
+ /**
+ * Value must be changed using {@link #setColor(String)}.
+ */
+ public int color;
+
+ /**
+ * Value must be changed using {@link #setBehaviour(CategoryBehaviour)}.
+ * Caller must also {@link #updateEnabledCategories()}.
+ */
+ @NonNull
+ public CategoryBehaviour behaviour = CategoryBehaviour.IGNORE;
+
+ SegmentCategory(String keyValue, StringRef title,
+ StringRef skipButtonText,
+ StringRef skippedToastText,
+ StringSetting behavior, StringSetting color) {
+ this(keyValue, title,
+ skipButtonText, skipButtonText, skipButtonText,
+ skippedToastText, skippedToastText, skippedToastText,
+ behavior, color);
+ }
+
+ SegmentCategory(String keyValue, StringRef title,
+ StringRef skipButtonTextBeginning, StringRef skipButtonTextMiddle, StringRef skipButtonTextEnd,
+ StringRef skippedToastBeginning, StringRef skippedToastMiddle, StringRef skippedToastEnd,
+ StringSetting behavior, StringSetting color) {
+ this.keyValue = Objects.requireNonNull(keyValue);
+ this.title = Objects.requireNonNull(title);
+ this.skipButtonTextBeginning = Objects.requireNonNull(skipButtonTextBeginning);
+ this.skipButtonTextMiddle = Objects.requireNonNull(skipButtonTextMiddle);
+ this.skipButtonTextEnd = Objects.requireNonNull(skipButtonTextEnd);
+ this.skippedToastBeginning = Objects.requireNonNull(skippedToastBeginning);
+ this.skippedToastMiddle = Objects.requireNonNull(skippedToastMiddle);
+ this.skippedToastEnd = Objects.requireNonNull(skippedToastEnd);
+ this.behaviorSetting = Objects.requireNonNull(behavior);
+ this.colorSetting = Objects.requireNonNull(color);
+ this.paint = new Paint();
+ loadFromSettings();
+ }
+
+ private void loadFromSettings() {
+ String behaviorString = behaviorSetting.get();
+ CategoryBehaviour savedBehavior = CategoryBehaviour.byReVancedKeyValue(behaviorString);
+ if (savedBehavior == null) {
+ Logger.printException(() -> "Invalid behavior: " + behaviorString);
+ behaviorSetting.resetToDefault();
+ loadFromSettings();
+ return;
+ }
+ this.behaviour = savedBehavior;
+
+ String colorString = colorSetting.get();
+ try {
+ setColor(colorString);
+ } catch (Exception ex) {
+ Logger.printException(() -> "Invalid color: " + colorString, ex);
+ colorSetting.resetToDefault();
+ loadFromSettings();
+ }
+ }
+
+ public void setBehaviour(@NonNull CategoryBehaviour behaviour) {
+ this.behaviour = Objects.requireNonNull(behaviour);
+ this.behaviorSetting.save(behaviour.reVancedKeyValue);
+ }
+
+ /**
+ * @return HTML color format string
+ */
+ @NonNull
+ public String colorString() {
+ return String.format("#%06X", color);
+ }
+
+ public void setColor(@NonNull String colorString) throws IllegalArgumentException {
+ final int color = Color.parseColor(colorString) & 0xFFFFFF;
+ this.color = color;
+ paint.setColor(color);
+ paint.setAlpha(255);
+ colorSetting.save(colorString); // Save after parsing.
+ }
+
+ public void resetColor() {
+ setColor(colorSetting.defaultValue);
+ }
+
+ @NonNull
+ private static String getCategoryColorDotHTML(int color) {
+ color &= 0xFFFFFF;
+ return String.format("⬤ ", color);
+ }
+
+ @NonNull
+ public static Spanned getCategoryColorDot(int color) {
+ return Html.fromHtml(getCategoryColorDotHTML(color));
+ }
+
+ @NonNull
+ public Spanned getCategoryColorDot() {
+ return getCategoryColorDot(color);
+ }
+
+ @NonNull
+ public Spanned getTitleWithColorDot() {
+ return Html.fromHtml(getCategoryColorDotHTML(color) + " " + title);
+ }
+
+ /**
+ * @param segmentStartTime video time the segment category started
+ * @param videoLength length of the video
+ * @return the skip button text
+ */
+ @NonNull
+ StringRef getSkipButtonText(long segmentStartTime, long videoLength) {
+ if (Settings.SB_COMPACT_SKIP_BUTTON.get()) {
+ return (this == SegmentCategory.HIGHLIGHT)
+ ? skipSponsorTextCompactHighlight
+ : skipSponsorTextCompact;
+ }
+
+ if (videoLength == 0) {
+ return skipButtonTextBeginning; // video is still loading. Assume it's the beginning
+ }
+ final float position = segmentStartTime / (float) videoLength;
+ if (position < 0.25f) {
+ return skipButtonTextBeginning;
+ } else if (position < 0.75f) {
+ return skipButtonTextMiddle;
+ }
+ return skipButtonTextEnd;
+ }
+
+ /**
+ * @param segmentStartTime video time the segment category started
+ * @param videoLength length of the video
+ * @return 'skipped segment' toast message
+ */
+ @NonNull
+ StringRef getSkippedToastText(long segmentStartTime, long videoLength) {
+ if (videoLength == 0) {
+ return skippedToastBeginning; // video is still loading. Assume it's the beginning
+ }
+ final float position = segmentStartTime / (float) videoLength;
+ if (position < 0.25f) {
+ return skippedToastBeginning;
+ } else if (position < 0.75f) {
+ return skippedToastMiddle;
+ }
+ return skippedToastEnd;
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SponsorSegment.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SponsorSegment.java
new file mode 100644
index 0000000000..51208c1ccf
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/SponsorSegment.java
@@ -0,0 +1,146 @@
+package app.revanced.extension.youtube.sponsorblock.objects;
+
+import static app.revanced.extension.shared.utils.StringRef.sf;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Objects;
+
+import app.revanced.extension.shared.utils.StringRef;
+import app.revanced.extension.youtube.sponsorblock.SegmentPlaybackController;
+
+public class SponsorSegment implements Comparable {
+ public enum SegmentVote {
+ UPVOTE(sf("revanced_sb_vote_upvote"), 1, false),
+ DOWNVOTE(sf("revanced_sb_vote_downvote"), 0, true),
+ CATEGORY_CHANGE(sf("revanced_sb_vote_category"), -1, true); // apiVoteType is not used for category change
+
+ public static final SegmentVote[] voteTypesWithoutCategoryChange = {
+ UPVOTE,
+ DOWNVOTE,
+ };
+
+ @NonNull
+ public final StringRef title;
+ public final int apiVoteType;
+ public final boolean shouldHighlight;
+
+ SegmentVote(@NonNull StringRef title, int apiVoteType, boolean shouldHighlight) {
+ this.title = title;
+ this.apiVoteType = apiVoteType;
+ this.shouldHighlight = shouldHighlight;
+ }
+ }
+
+ @NonNull
+ public final SegmentCategory category;
+ /**
+ * NULL if segment is unsubmitted
+ */
+ @Nullable
+ public final String UUID;
+ public final long start;
+ public final long end;
+ public final boolean isLocked;
+ public boolean didAutoSkipped = false;
+ /**
+ * If this segment has been counted as 'skipped'
+ */
+ public boolean recordedAsSkipped = false;
+
+ public SponsorSegment(@NonNull SegmentCategory category, @Nullable String UUID, long start, long end, boolean isLocked) {
+ this.category = category;
+ this.UUID = UUID;
+ this.start = start;
+ this.end = end;
+ this.isLocked = isLocked;
+ }
+
+ public boolean shouldAutoSkip() {
+ return category.behaviour.skipAutomatically && !(didAutoSkipped && category.behaviour == CategoryBehaviour.SKIP_AUTOMATICALLY_ONCE);
+ }
+
+ /**
+ * @param nearThreshold threshold to declare the time parameter is near this segment. Must be a positive number
+ */
+ public boolean startIsNear(long videoTime, long nearThreshold) {
+ return Math.abs(start - videoTime) <= nearThreshold;
+ }
+
+ /**
+ * @param nearThreshold threshold to declare the time parameter is near this segment. Must be a positive number
+ */
+ public boolean endIsNear(long videoTime, long nearThreshold) {
+ return Math.abs(end - videoTime) <= nearThreshold;
+ }
+
+ /**
+ * @return if the time parameter is within this segment
+ */
+ public boolean containsTime(long videoTime) {
+ return start <= videoTime && videoTime < end;
+ }
+
+ /**
+ * @return if the segment is completely contained inside this segment
+ */
+ public boolean containsSegment(SponsorSegment other) {
+ return start <= other.start && other.end <= end;
+ }
+
+ /**
+ * @return the length of this segment, in milliseconds. Always a positive number.
+ */
+ public long length() {
+ return end - start;
+ }
+
+ /**
+ * @return 'skip segment' UI overlay button text
+ */
+ @NonNull
+ public String getSkipButtonText() {
+ return category.getSkipButtonText(start, SegmentPlaybackController.getVideoLength()).toString();
+ }
+
+ /**
+ * @return 'skipped segment' toast message
+ */
+ @NonNull
+ public String getSkippedToastText() {
+ return category.getSkippedToastText(start, SegmentPlaybackController.getVideoLength()).toString();
+ }
+
+ @Override
+ public int compareTo(SponsorSegment o) {
+ // If both segments start at the same time, then sort with the longer segment first.
+ // This keeps the seekbar drawing correct since it draws the segments using the sorted order.
+ return start == o.start ? Long.compare(o.length(), length()) : Long.compare(start, o.start);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof SponsorSegment other)) return false;
+ return Objects.equals(UUID, other.UUID)
+ && category == other.category
+ && start == other.start
+ && end == other.end;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(UUID);
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "SponsorSegment{"
+ + "category=" + category
+ + ", start=" + start
+ + ", end=" + end
+ + '}';
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/UserStats.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/UserStats.java
new file mode 100644
index 0000000000..6a9b9a3e69
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/objects/UserStats.java
@@ -0,0 +1,53 @@
+package app.revanced.extension.youtube.sponsorblock.objects;
+
+import androidx.annotation.NonNull;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+/**
+ * SponsorBlock user stats
+ */
+public class UserStats {
+ @NonNull
+ public final String publicUserId;
+ @NonNull
+ public final String userName;
+ /**
+ * "User reputation". Unclear how SB determines this value.
+ */
+ public final float reputation;
+ /**
+ * {@link #segmentCount} plus {@link #ignoredSegmentCount}
+ */
+ public final int totalSegmentCountIncludingIgnored;
+ public final int segmentCount;
+ public final int ignoredSegmentCount;
+ public final int viewCount;
+ public final double minutesSaved;
+
+ public UserStats(@NonNull JSONObject json) throws JSONException {
+ publicUserId = json.getString("userID");
+ userName = json.getString("userName");
+ reputation = (float) json.getDouble("reputation");
+ segmentCount = json.getInt("segmentCount");
+ ignoredSegmentCount = json.getInt("ignoredSegmentCount");
+ totalSegmentCountIncludingIgnored = segmentCount + ignoredSegmentCount;
+ viewCount = json.getInt("viewCount");
+ minutesSaved = json.getDouble("minutesSaved");
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "UserStats{"
+ + "publicUserId='" + publicUserId + '\''
+ + ", userName='" + userName + '\''
+ + ", reputation=" + reputation
+ + ", segmentCount=" + segmentCount
+ + ", ignoredSegmentCount=" + ignoredSegmentCount
+ + ", viewCount=" + viewCount
+ + ", minutesSaved=" + minutesSaved
+ + '}';
+ }
+}
\ No newline at end of file
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRequester.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRequester.java
new file mode 100644
index 0000000000..1ff8a8d035
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/requests/SBRequester.java
@@ -0,0 +1,282 @@
+package app.revanced.extension.youtube.sponsorblock.requests;
+
+import static app.revanced.extension.shared.utils.StringRef.str;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.TimeUnit;
+
+import app.revanced.extension.shared.requests.Requester;
+import app.revanced.extension.shared.requests.Route;
+import app.revanced.extension.shared.sponsorblock.requests.SBRoutes;
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings;
+import app.revanced.extension.youtube.sponsorblock.objects.SegmentCategory;
+import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment;
+import app.revanced.extension.youtube.sponsorblock.objects.SponsorSegment.SegmentVote;
+import app.revanced.extension.youtube.sponsorblock.objects.UserStats;
+
+public class SBRequester {
+ private static final String TIME_TEMPLATE = "%.3f";
+
+ /**
+ * TCP timeout
+ */
+ private static final int TIMEOUT_TCP_DEFAULT_MILLISECONDS = 7000;
+
+ /**
+ * HTTP response timeout
+ */
+ private static final int TIMEOUT_HTTP_DEFAULT_MILLISECONDS = 10000;
+
+ /**
+ * Response code of a successful API call
+ */
+ private static final int HTTP_STATUS_CODE_SUCCESS = 200;
+
+ private SBRequester() {
+ }
+
+ private static void handleConnectionError(@NonNull String toastMessage, @Nullable Exception ex) {
+ if (Settings.SB_TOAST_ON_CONNECTION_ERROR.get()) {
+ Utils.showToastShort(toastMessage);
+ }
+ if (ex != null) {
+ Logger.printInfo(() -> toastMessage, ex);
+ }
+ }
+
+ @NonNull
+ public static SponsorSegment[] getSegments(@NonNull String videoId) {
+ Utils.verifyOffMainThread();
+ List segments = new ArrayList<>();
+ try {
+ HttpURLConnection connection = getConnectionFromRoute(SBRoutes.GET_SEGMENTS, videoId, SegmentCategory.sponsorBlockAPIFetchCategories);
+ final int responseCode = connection.getResponseCode();
+
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ JSONArray responseArray = Requester.parseJSONArray(connection);
+ final long minSegmentDuration = (long) (Settings.SB_SEGMENT_MIN_DURATION.get() * 1000);
+ for (int i = 0, length = responseArray.length(); i < length; i++) {
+ JSONObject obj = (JSONObject) responseArray.get(i);
+ JSONArray segment = obj.getJSONArray("segment");
+ final long start = (long) (segment.getDouble(0) * 1000);
+ final long end = (long) (segment.getDouble(1) * 1000);
+
+ String uuid = obj.getString("UUID");
+ final boolean locked = obj.getInt("locked") == 1;
+ String categoryKey = obj.getString("category");
+ SegmentCategory category = SegmentCategory.byCategoryKey(categoryKey);
+ if (category == null) {
+ Logger.printException(() -> "Received unknown category: " + categoryKey); // should never happen
+ } else if ((end - start) >= minSegmentDuration || category == SegmentCategory.HIGHLIGHT) {
+ segments.add(new SponsorSegment(category, uuid, start, end, locked));
+ }
+ }
+ Logger.printDebug(() -> {
+ StringBuilder builder = new StringBuilder("Downloaded segments:");
+ for (SponsorSegment segment : segments) {
+ builder.append('\n').append(segment);
+ }
+ return builder.toString();
+ });
+ runVipCheckInBackgroundIfNeeded();
+ } else if (responseCode == 404) {
+ // no segments are found. a normal response
+ Logger.printDebug(() -> "No segments found for video: " + videoId);
+ } else {
+ handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_status", responseCode), null);
+ connection.disconnect(); // something went wrong, might as well disconnect
+ }
+ } catch (SocketTimeoutException ex) {
+ handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_timeout"), ex);
+ } catch (IOException ex) {
+ handleConnectionError(str("revanced_sb_sponsorblock_connection_failure_generic"), ex);
+ } catch (Exception ex) {
+ // Should never happen
+ Logger.printException(() -> "getSegments failure", ex);
+ }
+
+ return segments.toArray(new SponsorSegment[0]);
+ }
+
+ public static void submitSegments(@NonNull String videoId, @NonNull String category,
+ long startTime, long endTime, long videoLength) {
+ Utils.verifyOffMainThread();
+ try {
+ String privateUserId = SponsorBlockSettings.getSBPrivateUserID();
+ String start = String.format(Locale.US, TIME_TEMPLATE, startTime / 1000f);
+ String end = String.format(Locale.US, TIME_TEMPLATE, endTime / 1000f);
+ String duration = String.format(Locale.US, TIME_TEMPLATE, videoLength / 1000f);
+
+ HttpURLConnection connection = getConnectionFromRoute(SBRoutes.SUBMIT_SEGMENTS, privateUserId, videoId, category, start, end, duration);
+ final int responseCode = connection.getResponseCode();
+
+ final String messageToToast = switch (responseCode) {
+ case HTTP_STATUS_CODE_SUCCESS -> str("revanced_sb_submit_succeeded");
+ case 409 -> str("revanced_sb_submit_failed_duplicate");
+ case 403 ->
+ str("revanced_sb_submit_failed_forbidden", Requester.parseErrorStringAndDisconnect(connection));
+ case 429 -> str("revanced_sb_submit_failed_rate_limit");
+ case 400 ->
+ str("revanced_sb_submit_failed_invalid", Requester.parseErrorStringAndDisconnect(connection));
+ default ->
+ str("revanced_sb_submit_failed_unknown_error", responseCode, connection.getResponseMessage());
+ };
+ Utils.showToastLong(messageToToast);
+ } catch (SocketTimeoutException ex) {
+ // Always show, even if show connection toasts is turned off
+ Utils.showToastLong(str("revanced_sb_submit_failed_timeout"));
+ } catch (IOException ex) {
+ Utils.showToastLong(str("revanced_sb_submit_failed_unknown_error", 0, ex.getMessage()));
+ } catch (Exception ex) {
+ Logger.printException(() -> "failed to submit segments", ex);
+ }
+ }
+
+ public static void sendSegmentSkippedViewedRequest(@NonNull SponsorSegment segment) {
+ Utils.verifyOffMainThread();
+ try {
+ HttpURLConnection connection = getConnectionFromRoute(SBRoutes.VIEWED_SEGMENT, segment.UUID);
+ final int responseCode = connection.getResponseCode();
+
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ Logger.printDebug(() -> "Successfully sent view count for segment: " + segment);
+ } else {
+ Logger.printDebug(() -> "Failed to sent view count for segment: " + segment.UUID
+ + " responseCode: " + responseCode); // debug level, no toast is shown
+ }
+ } catch (IOException ex) {
+ Logger.printInfo(() -> "Failed to send view count", ex); // do not show a toast
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to send view count request", ex); // should never happen
+ }
+ }
+
+ public static void voteForSegmentOnBackgroundThread(@NonNull SponsorSegment segment, @NonNull SegmentVote voteOption) {
+ voteOrRequestCategoryChange(segment, voteOption, null);
+ }
+
+ public static void voteToChangeCategoryOnBackgroundThread(@NonNull SponsorSegment segment, @NonNull SegmentCategory categoryToVoteFor) {
+ voteOrRequestCategoryChange(segment, SegmentVote.CATEGORY_CHANGE, categoryToVoteFor);
+ }
+
+ private static void voteOrRequestCategoryChange(@NonNull SponsorSegment segment, @NonNull SegmentVote voteOption, SegmentCategory categoryToVoteFor) {
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ String segmentUuid = segment.UUID;
+ String uuid = SponsorBlockSettings.getSBPrivateUserID();
+ HttpURLConnection connection = (voteOption == SegmentVote.CATEGORY_CHANGE)
+ ? getConnectionFromRoute(SBRoutes.VOTE_ON_SEGMENT_CATEGORY, uuid, segmentUuid, categoryToVoteFor.keyValue)
+ : getConnectionFromRoute(SBRoutes.VOTE_ON_SEGMENT_QUALITY, uuid, segmentUuid, String.valueOf(voteOption.apiVoteType));
+ final int responseCode = connection.getResponseCode();
+
+ switch (responseCode) {
+ case HTTP_STATUS_CODE_SUCCESS:
+ Logger.printDebug(() -> "Vote success for segment: " + segment);
+ break;
+ case 403:
+ Utils.showToastLong(
+ str("revanced_sb_vote_failed_forbidden", Requester.parseErrorStringAndDisconnect(connection)));
+ break;
+ default:
+ Utils.showToastLong(
+ str("revanced_sb_vote_failed_unknown_error", responseCode, connection.getResponseMessage()));
+ break;
+ }
+ } catch (SocketTimeoutException ex) {
+ Utils.showToastShort(str("revanced_sb_vote_failed_timeout"));
+ } catch (IOException ex) {
+ Utils.showToastShort(str("revanced_sb_vote_failed_unknown_error", 0, ex.getMessage()));
+ } catch (Exception ex) {
+ Logger.printException(() -> "failed to vote for segment", ex); // should never happen
+ }
+ });
+ }
+
+ /**
+ * @return NULL, if stats fetch failed
+ */
+ @Nullable
+ public static UserStats retrieveUserStats() {
+ Utils.verifyOffMainThread();
+ try {
+ UserStats stats = new UserStats(getJSONObject(SBRoutes.GET_USER_STATS, SponsorBlockSettings.getSBPrivateUserID()));
+ Logger.printDebug(() -> "user stats: " + stats);
+ return stats;
+ } catch (IOException ex) {
+ Logger.printInfo(() -> "failed to retrieve user stats", ex); // info level, do not show a toast
+ } catch (Exception ex) {
+ Logger.printException(() -> "failure retrieving user stats", ex); // should never happen
+ }
+ return null;
+ }
+
+ /**
+ * @return NULL if the call was successful. If unsuccessful, an error message is returned.
+ */
+ @Nullable
+ public static String setUsername(@NonNull String username) {
+ Utils.verifyOffMainThread();
+ try {
+ HttpURLConnection connection = getConnectionFromRoute(SBRoutes.CHANGE_USERNAME, SponsorBlockSettings.getSBPrivateUserID(), username);
+ final int responseCode = connection.getResponseCode();
+ String responseMessage = connection.getResponseMessage();
+ if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
+ return null;
+ }
+ return str("revanced_sb_stats_username_change_unknown_error", responseCode, responseMessage);
+ } catch (Exception ex) { // should never happen
+ Logger.printInfo(() -> "failed to set username", ex); // do not toast
+ return str("revanced_sb_stats_username_change_unknown_error", 0, ex.getMessage());
+ }
+ }
+
+ public static void runVipCheckInBackgroundIfNeeded() {
+ if (!SponsorBlockSettings.userHasSBPrivateId()) {
+ return; // User cannot be a VIP. User has never voted, created any segments, or has imported a SB user id.
+ }
+ long now = System.currentTimeMillis();
+ if (now < (Settings.SB_LAST_VIP_CHECK.get() + TimeUnit.DAYS.toMillis(3))) {
+ return;
+ }
+ Utils.runOnBackgroundThread(() -> {
+ try {
+ JSONObject json = getJSONObject(SBRoutes.IS_USER_VIP, SponsorBlockSettings.getSBPrivateUserID());
+ boolean vip = json.getBoolean("vip");
+ Settings.SB_USER_IS_VIP.save(vip);
+ Settings.SB_LAST_VIP_CHECK.save(now);
+ } catch (IOException ex) {
+ Logger.printInfo(() -> "Failed to check VIP (network error)", ex); // info, so no error toast is shown
+ } catch (Exception ex) {
+ Logger.printException(() -> "Failed to check VIP", ex); // should never happen
+ }
+ });
+ }
+
+ // helpers
+
+ private static HttpURLConnection getConnectionFromRoute(@NonNull Route route, String... params) throws IOException {
+ HttpURLConnection connection = Requester.getConnectionFromRoute(Settings.SB_API_URL.get(), route, params);
+ connection.setConnectTimeout(TIMEOUT_TCP_DEFAULT_MILLISECONDS);
+ connection.setReadTimeout(TIMEOUT_HTTP_DEFAULT_MILLISECONDS);
+ return connection;
+ }
+
+ private static JSONObject getJSONObject(@NonNull Route route, String... params) throws IOException, JSONException {
+ return Requester.parseJSONObject(getConnectionFromRoute(route, params));
+ }
+}
diff --git a/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/CreateSegmentButtonController.java b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/CreateSegmentButtonController.java
new file mode 100644
index 0000000000..44478658a7
--- /dev/null
+++ b/extensions/shared/src/main/java/app/revanced/extension/youtube/sponsorblock/ui/CreateSegmentButtonController.java
@@ -0,0 +1,89 @@
+package app.revanced.extension.youtube.sponsorblock.ui;
+
+import static app.revanced.extension.shared.utils.Utils.getChildView;
+
+import android.view.View;
+import android.widget.ImageView;
+
+import java.lang.ref.WeakReference;
+import java.util.Objects;
+
+import app.revanced.extension.shared.utils.Logger;
+import app.revanced.extension.shared.utils.Utils;
+import app.revanced.extension.youtube.patches.overlaybutton.BottomControlButton;
+import app.revanced.extension.youtube.settings.Settings;
+import app.revanced.extension.youtube.shared.VideoInformation;
+
+@SuppressWarnings("unused")
+public class CreateSegmentButtonController {
+ private static WeakReference