mirror of
https://github.com/revanced/revanced-integrations.git
synced 2024-11-23 12:26:52 +01:00
feat(YouTube): Support versions 19.25
and 19.34
(#689)
Co-authored-by: LisoUseInAIKyrios <118716522+LisoUseInAIKyrios@users.noreply.github.com> Co-authored-by: kitadai31 <90122968+kitadai31@users.noreply.github.com>
This commit is contained in:
parent
99d17bf026
commit
61569ba111
@ -1,10 +1,7 @@
|
||||
package app.revanced.integrations.shared;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
import android.app.Dialog;
|
||||
import android.app.DialogFragment;
|
||||
import android.app.*;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageInfo;
|
||||
@ -268,6 +265,20 @@ public class Utils {
|
||||
boolean matches(T object);
|
||||
}
|
||||
|
||||
/**
|
||||
* Includes sub children.
|
||||
*
|
||||
* @noinspection unchecked
|
||||
*/
|
||||
public static <R extends View> R getChildViewByResourceName(@NonNull View view, @NonNull String str) {
|
||||
var child = view.findViewById(Utils.getResourceIdentifier(str, "id"));
|
||||
if (child != null) {
|
||||
return (R) child;
|
||||
}
|
||||
|
||||
throw new IllegalArgumentException("View with resource name '" + str + "' not found");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param searchRecursively If children ViewGroups should also be
|
||||
* recursively searched using depth first search.
|
||||
@ -710,4 +721,21 @@ public class Utils {
|
||||
pref.setOrder(order);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If {@link Fragment} uses [Android library] rather than [AndroidX library],
|
||||
* the Dialog theme corresponding to [Android library] should be used.
|
||||
* <p>
|
||||
* If not, the following issues will occur:
|
||||
* <a href="https://github.com/ReVanced/revanced-patches/issues/3061">ReVanced/revanced-patches#3061</a>
|
||||
* <p>
|
||||
* To prevent these issues, apply the Dialog theme corresponding to [Android library].
|
||||
*/
|
||||
public static void setEditTextDialogTheme(AlertDialog.Builder builder) {
|
||||
final int editTextDialogStyle = getResourceIdentifier(
|
||||
"revanced_edit_text_dialog_style", "style");
|
||||
if (editTextDialogStyle != 0) {
|
||||
builder.getContext().setTheme(editTextDialogStyle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.Utils;
|
||||
import app.revanced.integrations.shared.settings.BaseSettings;
|
||||
import app.revanced.integrations.shared.settings.BooleanSetting;
|
||||
import app.revanced.integrations.shared.settings.Setting;
|
||||
|
||||
@ -141,8 +142,13 @@ public abstract class AbstractPreferenceFragment extends PreferenceFragment {
|
||||
} else if (pref.hasKey()) {
|
||||
String key = pref.getKey();
|
||||
Setting<?> setting = Setting.getSettingFromPath(key);
|
||||
|
||||
if (setting != null) {
|
||||
updatePreference(pref, setting, syncSettingValue, applySettingToPreference);
|
||||
} else if (BaseSettings.DEBUG.get() && (pref instanceof SwitchPreference
|
||||
|| pref instanceof EditTextPreference || pref instanceof ListPreference)) {
|
||||
// Probably a typo in the patches preference declaration.
|
||||
Logger.printException(() -> "Preference key has no setting: " + key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -66,6 +66,8 @@ public class ImportExportPreference extends EditTextPreference implements Prefer
|
||||
@Override
|
||||
protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
|
||||
try {
|
||||
Utils.setEditTextDialogTheme(builder);
|
||||
|
||||
// Show the user the settings in JSON format.
|
||||
builder.setNeutralButton(str("revanced_settings_import_copy"), (dialog, which) -> {
|
||||
Utils.setClipboard(getEditText().getText().toString());
|
||||
|
@ -7,6 +7,8 @@ import android.preference.EditTextPreference;
|
||||
import android.util.AttributeSet;
|
||||
import android.widget.Button;
|
||||
import android.widget.EditText;
|
||||
|
||||
import app.revanced.integrations.shared.Utils;
|
||||
import app.revanced.integrations.shared.settings.Setting;
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
|
||||
@ -33,6 +35,8 @@ public class ResettableEditTextPreference extends EditTextPreference {
|
||||
@Override
|
||||
protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
|
||||
super.onPrepareDialogBuilder(builder);
|
||||
Utils.setEditTextDialogTheme(builder);
|
||||
|
||||
Setting<?> setting = Setting.getSettingFromPath(getKey());
|
||||
if (setting != null) {
|
||||
builder.setNeutralButton(str("revanced_settings_reset"), null);
|
||||
|
@ -8,7 +8,9 @@ public class BackgroundPlaybackPatch {
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static boolean playbackIsNotShort() {
|
||||
public static boolean allowBackgroundPlayback(boolean original) {
|
||||
if (original) return true;
|
||||
|
||||
// Steps to verify most edge cases:
|
||||
// 1. Open a regular video
|
||||
// 2. Minimize app (PIP should appear)
|
||||
|
@ -1,21 +1,129 @@
|
||||
package app.revanced.integrations.youtube.patches;
|
||||
|
||||
import static java.lang.Boolean.FALSE;
|
||||
import static java.lang.Boolean.TRUE;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.youtube.settings.Settings;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public final class ChangeStartPagePatch {
|
||||
public static void changeIntent(final Intent intent) {
|
||||
final var startPage = Settings.START_PAGE.get();
|
||||
if (startPage.isEmpty()) return;
|
||||
|
||||
Logger.printDebug(() -> "Changing start page to " + startPage);
|
||||
public enum StartPage {
|
||||
/**
|
||||
* Unmodified type, and same as un-patched.
|
||||
*/
|
||||
ORIGINAL("", null),
|
||||
|
||||
if (startPage.startsWith("www"))
|
||||
intent.setData(Uri.parse(startPage));
|
||||
else
|
||||
intent.setAction("com.google.android.youtube.action." + startPage);
|
||||
/**
|
||||
* 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 TRUE.equals(isBrowseId);
|
||||
}
|
||||
|
||||
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
|
||||
private boolean isIntentAction() {
|
||||
return FALSE.equals(isBrowseId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intent action when YouTube is cold started from the launcher.
|
||||
* <p>
|
||||
* 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();
|
||||
|
||||
/**
|
||||
* 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 (appLaunched) {
|
||||
Logger.printDebug(() -> "Ignore override browseId as the app already launched");
|
||||
return original;
|
||||
}
|
||||
appLaunched = true;
|
||||
|
||||
Logger.printDebug(() -> "Changing browseId to " + START_PAGE.id);
|
||||
return START_PAGE.id;
|
||||
}
|
||||
|
||||
public static void overrideIntentAction(@NonNull Intent intent) {
|
||||
if (!START_PAGE.isIntentAction()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ACTION_MAIN.equals(intent.getAction())) {
|
||||
Logger.printDebug(() -> "Ignore override intent action" +
|
||||
" as the current activity is not the entry point of the application");
|
||||
return;
|
||||
}
|
||||
|
||||
if (appLaunched) {
|
||||
Logger.printDebug(() -> "Ignore override intent action as the app already launched");
|
||||
return;
|
||||
}
|
||||
appLaunched = true;
|
||||
|
||||
final String intentAction = START_PAGE.id;
|
||||
Logger.printDebug(() -> "Changing intent action to " + intentAction);
|
||||
intent.setAction(intentAction);
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,47 @@
|
||||
package app.revanced.integrations.youtube.patches;
|
||||
|
||||
import android.view.View;
|
||||
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.Utils;
|
||||
import app.revanced.integrations.youtube.settings.Settings;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public final class HidePlayerButtonsPatch {
|
||||
|
||||
private static final boolean HIDE_PLAYER_BUTTONS_ENABLED = Settings.HIDE_PLAYER_BUTTONS.get();
|
||||
|
||||
private static final int PLAYER_CONTROL_PREVIOUS_BUTTON_TOUCH_AREA_ID =
|
||||
Utils.getResourceIdentifier("player_control_previous_button_touch_area", "id");
|
||||
|
||||
private static final int PLAYER_CONTROL_NEXT_BUTTON_TOUCH_AREA_ID =
|
||||
Utils.getResourceIdentifier("player_control_next_button_touch_area", "id");
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static boolean previousOrNextButtonIsVisible(boolean previousOrNextButtonVisible) {
|
||||
if (Settings.HIDE_PLAYER_BUTTONS.get()) {
|
||||
return false;
|
||||
public static void hidePreviousNextButtons(View parentView) {
|
||||
if (!HIDE_PLAYER_BUTTONS_ENABLED) {
|
||||
return;
|
||||
}
|
||||
return previousOrNextButtonVisible;
|
||||
|
||||
// Must use a deferred call to main thread to hide the button.
|
||||
// Otherwise the layout crashes if set to hidden now.
|
||||
Utils.runOnMainThread(() -> {
|
||||
hideView(parentView, PLAYER_CONTROL_PREVIOUS_BUTTON_TOUCH_AREA_ID);
|
||||
hideView(parentView, PLAYER_CONTROL_NEXT_BUTTON_TOUCH_AREA_ID);
|
||||
});
|
||||
}
|
||||
|
||||
private static void hideView(View parentView, int resourceId) {
|
||||
View nextPreviousButton = parentView.findViewById(resourceId);
|
||||
|
||||
if (nextPreviousButton == null) {
|
||||
Logger.printException(() -> "Could not find player previous/next button");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.printDebug(() -> "Hiding previous/next button");
|
||||
Utils.hideViewByRemovingFromParentUnderCondition(true, nextPreviousButton);
|
||||
}
|
||||
}
|
||||
|
@ -2,20 +2,22 @@ package app.revanced.integrations.youtube.patches;
|
||||
|
||||
import static app.revanced.integrations.shared.StringRef.str;
|
||||
import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.*;
|
||||
import static app.revanced.integrations.youtube.patches.VersionCheckPatch.*;
|
||||
|
||||
import android.util.DisplayMetrics;
|
||||
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.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.Utils;
|
||||
import app.revanced.integrations.shared.settings.Setting;
|
||||
import app.revanced.integrations.youtube.settings.Settings;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
@SuppressWarnings({"unused", "SpellCheckingInspection"})
|
||||
public final class MiniplayerPatch {
|
||||
|
||||
/**
|
||||
@ -28,7 +30,12 @@ public final class MiniplayerPatch {
|
||||
TABLET(true, null),
|
||||
MODERN_1(null, 1),
|
||||
MODERN_2(null, 2),
|
||||
MODERN_3(null, 3);
|
||||
MODERN_3(null, 3),
|
||||
/**
|
||||
* Half broken miniplayer, that might be work in progress or left over abandoned code.
|
||||
* Can force this type by editing the import/export settings.
|
||||
*/
|
||||
MODERN_4(null, 4);
|
||||
|
||||
/**
|
||||
* Legacy tablet hook value.
|
||||
@ -52,6 +59,35 @@ public final class MiniplayerPatch {
|
||||
}
|
||||
}
|
||||
|
||||
private static final int MINIPLAYER_SIZE;
|
||||
|
||||
static {
|
||||
// YT appears to use the device screen dip width, plus an unknown fixed horizontal padding size.
|
||||
DisplayMetrics displayMetrics = Utils.getContext().getResources().getDisplayMetrics();
|
||||
final int deviceDipWidth = (int) (displayMetrics.widthPixels / displayMetrics.density);
|
||||
|
||||
// YT seems to use a minimum height to calculate the minimum miniplayer width based on the video.
|
||||
// 170 seems to be the smallest that can be used and using less makes no difference.
|
||||
final int WIDTH_DIP_MIN = 170; // Seems to be the smallest that works.
|
||||
final int HORIZONTAL_PADDING_DIP = 15; // Estimated padding.
|
||||
// Round down to the nearest 5 pixels, to keep any error toasts easier to read.
|
||||
final int WIDTH_DIP_MAX = 5 * ((deviceDipWidth - HORIZONTAL_PADDING_DIP) / 5);
|
||||
Logger.printDebug(() -> "Screen dip width: " + deviceDipWidth + " maxWidth: " + WIDTH_DIP_MAX);
|
||||
|
||||
int dipWidth = Settings.MINIPLAYER_WIDTH_DIP.get();
|
||||
|
||||
if (dipWidth < WIDTH_DIP_MIN || dipWidth > WIDTH_DIP_MAX) {
|
||||
Utils.showToastLong(str("revanced_miniplayer_width_dip_invalid_toast",
|
||||
WIDTH_DIP_MIN, WIDTH_DIP_MAX));
|
||||
|
||||
// Instead of resetting, clamp the size at the bounds.
|
||||
dipWidth = Math.max(WIDTH_DIP_MIN, Math.min(dipWidth, WIDTH_DIP_MAX));
|
||||
Settings.MINIPLAYER_WIDTH_DIP.save(dipWidth);
|
||||
}
|
||||
|
||||
MINIPLAYER_SIZE = dipWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modern subtitle overlay for {@link MiniplayerType#MODERN_2}.
|
||||
* Resource is not present in older targets, and this field will be zero.
|
||||
@ -61,8 +97,21 @@ public final class MiniplayerPatch {
|
||||
|
||||
private static final MiniplayerType CURRENT_TYPE = Settings.MINIPLAYER_TYPE.get();
|
||||
|
||||
/**
|
||||
* Cannot turn off double tap with modern 2 or 3 with later targets,
|
||||
* as forcing it off breakings tapping the miniplayer.
|
||||
*/
|
||||
private static final boolean DOUBLE_TAP_ACTION_ENABLED =
|
||||
// 19.29+ is very broken if double tap is not enabled.
|
||||
IS_19_29_OR_GREATER ||
|
||||
(CURRENT_TYPE.isModern() && Settings.MINIPLAYER_DOUBLE_TAP_ACTION.get());
|
||||
|
||||
private static final boolean DRAG_AND_DROP_ENABLED =
|
||||
CURRENT_TYPE.isModern() && Settings.MINIPLAYER_DRAG_AND_DROP.get();
|
||||
|
||||
private static final boolean HIDE_EXPAND_CLOSE_ENABLED =
|
||||
(CURRENT_TYPE == MODERN_1 || CURRENT_TYPE == MODERN_3) && Settings.MINIPLAYER_HIDE_EXPAND_CLOSE.get();
|
||||
Settings.MINIPLAYER_HIDE_EXPAND_CLOSE.get()
|
||||
&& Settings.MINIPLAYER_HIDE_EXPAND_CLOSE.isAvailable();
|
||||
|
||||
private static final boolean HIDE_SUBTEXT_ENABLED =
|
||||
(CURRENT_TYPE == MODERN_1 || CURRENT_TYPE == MODERN_3) && Settings.MINIPLAYER_HIDE_SUBTEXT.get();
|
||||
@ -70,8 +119,29 @@ public final class MiniplayerPatch {
|
||||
private static final boolean HIDE_REWIND_FORWARD_ENABLED =
|
||||
CURRENT_TYPE == MODERN_1 && Settings.MINIPLAYER_HIDE_REWIND_FORWARD.get();
|
||||
|
||||
private static final boolean MINIPLAYER_ROUNDED_CORNERS_ENABLED =
|
||||
Settings.MINIPLAYER_ROUNDED_CORNERS.get();
|
||||
|
||||
/**
|
||||
* Remove a broken and always present subtitle text that is only
|
||||
* present with {@link MiniplayerType#MODERN_2}. Bug was fixed in 19.21.
|
||||
*/
|
||||
private static final boolean HIDE_BROKEN_MODERN_2_SUBTITLE =
|
||||
CURRENT_TYPE == MODERN_2 && !IS_19_21_OR_GREATER;
|
||||
|
||||
private static final int OPACITY_LEVEL;
|
||||
|
||||
public static final class MiniplayerHideExpandCloseAvailability implements Setting.Availability {
|
||||
@Override
|
||||
public boolean isAvailable() {
|
||||
MiniplayerType type = Settings.MINIPLAYER_TYPE.get();
|
||||
return (!IS_19_20_OR_GREATER && (type == MODERN_1 || type == MODERN_3))
|
||||
|| (!IS_19_26_OR_GREATER && type == MODERN_1
|
||||
&& !Settings.MINIPLAYER_DOUBLE_TAP_ACTION.get() && !Settings.MINIPLAYER_DRAG_AND_DROP.get())
|
||||
|| (IS_19_29_OR_GREATER && type == MODERN_3);
|
||||
}
|
||||
}
|
||||
|
||||
static {
|
||||
int opacity = Settings.MINIPLAYER_OPACITY.get();
|
||||
|
||||
@ -122,6 +192,90 @@ public final class MiniplayerPatch {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static boolean getModernFeatureFlagsActiveOverride(boolean original) {
|
||||
if (original) Logger.printDebug(() -> "getModernFeatureFlagsActiveOverride original: " + original);
|
||||
|
||||
if (CURRENT_TYPE == ORIGINAL) {
|
||||
return original;
|
||||
}
|
||||
|
||||
return CURRENT_TYPE.isModern();
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static boolean enableMiniplayerDoubleTapAction(boolean original) {
|
||||
if (original) Logger.printDebug(() -> "enableMiniplayerDoubleTapAction original: " + true);
|
||||
|
||||
if (CURRENT_TYPE == ORIGINAL) {
|
||||
return original;
|
||||
}
|
||||
|
||||
return DOUBLE_TAP_ACTION_ENABLED;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static boolean enableMiniplayerDragAndDrop(boolean original) {
|
||||
if (original) Logger.printDebug(() -> "enableMiniplayerDragAndDrop original: " + true);
|
||||
|
||||
if (CURRENT_TYPE == ORIGINAL) {
|
||||
return original;
|
||||
}
|
||||
|
||||
return DRAG_AND_DROP_ENABLED;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static boolean setRoundedCorners(boolean original) {
|
||||
if (original) Logger.printDebug(() -> "setRoundedCorners original: " + true);
|
||||
|
||||
if (CURRENT_TYPE.isModern()) {
|
||||
return MINIPLAYER_ROUNDED_CORNERS_ENABLED;
|
||||
}
|
||||
|
||||
return original;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static int setMiniplayerDefaultSize(int original) {
|
||||
if (CURRENT_TYPE.isModern()) {
|
||||
return MINIPLAYER_SIZE;
|
||||
}
|
||||
|
||||
return original;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static float setMovementBoundFactor(float original) {
|
||||
// Not clear if customizing this is useful or not.
|
||||
// So for now just log this and use the original value.
|
||||
if (original != 1.0) Logger.printDebug(() -> "setMovementBoundFactor original: " + original);
|
||||
|
||||
return original;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static boolean setDropShadow(boolean original) {
|
||||
if (original) Logger.printDebug(() -> "setViewElevation original: " + true);
|
||||
|
||||
return original;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
@ -140,18 +294,23 @@ public final class MiniplayerPatch {
|
||||
* Injection point.
|
||||
*/
|
||||
public static void 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);
|
||||
try {
|
||||
// Different subviews are passed in, but only TextView is of interest here.
|
||||
if (HIDE_SUBTEXT_ENABLED && view instanceof TextView) {
|
||||
Logger.printDebug(() -> "Hiding subtext view");
|
||||
Utils.hideViewByRemovingFromParentUnderCondition(true, view);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "hideMiniplayerSubTexts failure", ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
try {
|
||||
if (HIDE_BROKEN_MODERN_2_SUBTITLE && MODERN_OVERLAY_SUBTITLE_TEXT != 0) {
|
||||
if (group instanceof ViewGroup) {
|
||||
View subtitleText = Utils.getChildView((ViewGroup) group, true,
|
||||
view -> view.getId() == MODERN_OVERLAY_SUBTITLE_TEXT);
|
||||
@ -162,5 +321,8 @@ public final class MiniplayerPatch {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "playerOverlayGroupCreated failure", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -699,10 +699,12 @@ public class ReturnYouTubeDislikePatch {
|
||||
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");
|
||||
@ -723,6 +725,7 @@ public class ReturnYouTubeDislikePatch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Logger.printException(() -> "Unknown vote type: " + vote);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "sendVote failure", ex);
|
||||
|
@ -4,7 +4,11 @@ import app.revanced.integrations.youtube.settings.Settings;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public final class SlideToSeekPatch {
|
||||
public static boolean isSlideToSeekDisabled() {
|
||||
return !Settings.SLIDE_TO_SEEK.get();
|
||||
private static final Boolean SLIDE_TO_SEEK_DISABLED = !Settings.SLIDE_TO_SEEK.get();
|
||||
|
||||
public static boolean isSlideToSeekDisabled(boolean isDisabled) {
|
||||
if (!isDisabled) return isDisabled;
|
||||
|
||||
return SLIDE_TO_SEEK_DISABLED;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,10 @@
|
||||
package app.revanced.integrations.youtube.patches;
|
||||
|
||||
import app.revanced.integrations.shared.Utils;
|
||||
|
||||
public class VersionCheckPatch {
|
||||
public static final boolean IS_19_20_OR_GREATER = Utils.getAppVersionName().compareTo("19.20.00") >= 0;
|
||||
public static final boolean IS_19_21_OR_GREATER = Utils.getAppVersionName().compareTo("19.21.00") >= 0;
|
||||
public static final boolean IS_19_26_OR_GREATER = Utils.getAppVersionName().compareTo("19.26.00") >= 0;
|
||||
public static final boolean IS_19_29_OR_GREATER = Utils.getAppVersionName().compareTo("19.29.00") >= 0;
|
||||
}
|
@ -18,7 +18,7 @@ public final class VideoInformation {
|
||||
public interface PlaybackController {
|
||||
// Methods are added to YT classes during patching.
|
||||
boolean seekTo(long videoTime);
|
||||
boolean seekToRelative(long videoTimeOffset);
|
||||
void seekToRelative(long videoTimeOffset);
|
||||
}
|
||||
|
||||
private static final float DEFAULT_YOUTUBE_PLAYBACK_SPEED = 1.0f;
|
||||
@ -229,21 +229,19 @@ public final class VideoInformation {
|
||||
/**
|
||||
* 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) {
|
||||
public static void seekToRelative(long seekTime) {
|
||||
Utils.verifyOnMainThread();
|
||||
try {
|
||||
Logger.printDebug(() -> "Seeking relative to: " + seekTime);
|
||||
|
||||
// Try regular playback controller first, and it will not succeed if casting.
|
||||
// 19.39+ does not have a boolean return type for relative seek.
|
||||
// But can call both methods and it works correctly for both situations.
|
||||
PlaybackController controller = playerControllerRef.get();
|
||||
if (controller == null) {
|
||||
Logger.printDebug(() -> "Cannot seek relative as player controller is null");
|
||||
} else {
|
||||
if (controller.seekToRelative(seekTime)) return true;
|
||||
Logger.printDebug(() -> "seekToRelative did not succeeded. Trying MXD.");
|
||||
controller.seekToRelative(seekTime);
|
||||
}
|
||||
|
||||
// Adjust the fine adjustment function so it's at least 1 second before/after.
|
||||
@ -258,13 +256,11 @@ public final class VideoInformation {
|
||||
controller = mdxPlayerDirectorRef.get();
|
||||
if (controller == null) {
|
||||
Logger.printDebug(() -> "Cannot seek relative as MXD player controller is null");
|
||||
return false;
|
||||
} else {
|
||||
controller.seekToRelative(adjustedSeekTime);
|
||||
}
|
||||
|
||||
return controller.seekToRelative(adjustedSeekTime);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "Failed to seek relative", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,90 @@
|
||||
package app.revanced.integrations.youtube.patches.components;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.settings.BaseSettings;
|
||||
|
||||
/**
|
||||
* 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, 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.
|
||||
*/
|
||||
abstract class Filter {
|
||||
|
||||
public enum FilterContentType {
|
||||
IDENTIFIER,
|
||||
PATH,
|
||||
PROTOBUFFER
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifier callbacks. Do not add to this instance,
|
||||
* and instead use {@link #addIdentifierCallbacks(StringFilterGroup...)}.
|
||||
*/
|
||||
protected final List<StringFilterGroup> identifierCallbacks = new ArrayList<>();
|
||||
/**
|
||||
* Path callbacks. Do not add to this instance,
|
||||
* and instead use {@link #addPathCallbacks(StringFilterGroup...)}.
|
||||
*/
|
||||
protected final List<StringFilterGroup> pathCallbacks = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Adds callbacks to {@link #isFiltered(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, byte[], StringFilterGroup, FilterContentType, int)}
|
||||
* if any of the groups are found.
|
||||
*/
|
||||
protected final void addPathCallbacks(StringFilterGroup... groups) {
|
||||
pathCallbacks.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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
*/
|
||||
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
|
||||
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
|
||||
if (BaseSettings.DEBUG.get()) {
|
||||
String filterSimpleName = getClass().getSimpleName();
|
||||
if (contentType == FilterContentType.IDENTIFIER) {
|
||||
Logger.printDebug(() -> filterSimpleName + " Filtered identifier: " + identifier);
|
||||
} else {
|
||||
Logger.printDebug(() -> filterSimpleName + " Filtered path: " + path);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,214 @@
|
||||
package app.revanced.integrations.youtube.patches.components;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.settings.BooleanSetting;
|
||||
import app.revanced.integrations.youtube.ByteTrieSearch;
|
||||
|
||||
abstract class FilterGroup<T> {
|
||||
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);
|
||||
}
|
||||
|
||||
class StringFilterGroup extends FilterGroup<String> {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class ByteArrayFilterGroup extends FilterGroup<byte[]> {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,85 @@
|
||||
package app.revanced.integrations.youtube.patches.components;
|
||||
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import app.revanced.integrations.youtube.ByteTrieSearch;
|
||||
import app.revanced.integrations.youtube.StringTrieSearch;
|
||||
import app.revanced.integrations.youtube.TrieSearch;
|
||||
|
||||
abstract class FilterGroupList<V, T extends FilterGroup<V>> implements Iterable<T> {
|
||||
|
||||
private final List<T> filterGroups = new ArrayList<>();
|
||||
private final TrieSearch<V> 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, matchedLength);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Iterator<T> iterator() {
|
||||
return filterGroups.iterator();
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.N)
|
||||
@Override
|
||||
public void forEach(@NonNull Consumer<? super T> action) {
|
||||
filterGroups.forEach(action);
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.N)
|
||||
@NonNull
|
||||
@Override
|
||||
public Spliterator<T> 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<V> createSearchGraph();
|
||||
}
|
||||
|
||||
final class StringFilterGroupList extends FilterGroupList<String, StringFilterGroup> {
|
||||
protected StringTrieSearch createSearchGraph() {
|
||||
return new StringTrieSearch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
final class ByteArrayFilterGroupList extends FilterGroupList<byte[], ByteArrayFilterGroup> {
|
||||
protected ByteTrieSearch createSearchGraph() {
|
||||
return new ByteTrieSearch();
|
||||
}
|
||||
}
|
@ -423,7 +423,6 @@ public final class LayoutComponentsFilter extends Filter {
|
||||
// Check navigation button last.
|
||||
// Only filter if the library tab is not selected.
|
||||
// This check is important as the shelf layout is used for the library tab playlists.
|
||||
NavigationButton selectedNavButton = NavigationButton.getSelectedNavigationButton();
|
||||
return selectedNavButton != null && !selectedNavButton.isLibraryOrYouTab();
|
||||
return NavigationButton.getSelectedNavigationButton() != NavigationButton.LIBRARY;
|
||||
}
|
||||
}
|
||||
|
@ -1,389 +1,15 @@
|
||||
package app.revanced.integrations.youtube.patches.components;
|
||||
|
||||
import android.os.Build;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.RequiresApi;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
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.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.settings.BooleanSetting;
|
||||
import app.revanced.integrations.shared.settings.BaseSettings;
|
||||
import app.revanced.integrations.youtube.ByteTrieSearch;
|
||||
import app.revanced.integrations.youtube.StringTrieSearch;
|
||||
import app.revanced.integrations.youtube.TrieSearch;
|
||||
import app.revanced.integrations.youtube.settings.Settings;
|
||||
|
||||
abstract class FilterGroup<T> {
|
||||
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);
|
||||
}
|
||||
|
||||
class StringFilterGroup extends FilterGroup<String> {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class ByteArrayFilterGroup extends FilterGroup<byte[]> {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
abstract class FilterGroupList<V, T extends FilterGroup<V>> implements Iterable<T> {
|
||||
|
||||
private final List<T> filterGroups = new ArrayList<>();
|
||||
private final TrieSearch<V> 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, matchedLength);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Iterator<T> iterator() {
|
||||
return filterGroups.iterator();
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.N)
|
||||
@Override
|
||||
public void forEach(@NonNull Consumer<? super T> action) {
|
||||
filterGroups.forEach(action);
|
||||
}
|
||||
|
||||
@RequiresApi(api = Build.VERSION_CODES.N)
|
||||
@NonNull
|
||||
@Override
|
||||
public Spliterator<T> 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<V> createSearchGraph();
|
||||
}
|
||||
|
||||
final class StringFilterGroupList extends FilterGroupList<String, StringFilterGroup> {
|
||||
protected StringTrieSearch createSearchGraph() {
|
||||
return new StringTrieSearch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
final class ByteArrayFilterGroupList extends FilterGroupList<byte[], ByteArrayFilterGroup> {
|
||||
protected ByteTrieSearch createSearchGraph() {
|
||||
return new ByteTrieSearch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, 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.
|
||||
*/
|
||||
abstract class Filter {
|
||||
|
||||
public enum FilterContentType {
|
||||
IDENTIFIER,
|
||||
PATH,
|
||||
PROTOBUFFER
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifier callbacks. Do not add to this instance,
|
||||
* and instead use {@link #addIdentifierCallbacks(StringFilterGroup...)}.
|
||||
*/
|
||||
protected final List<StringFilterGroup> identifierCallbacks = new ArrayList<>();
|
||||
/**
|
||||
* Path callbacks. Do not add to this instance,
|
||||
* and instead use {@link #addPathCallbacks(StringFilterGroup...)}.
|
||||
*/
|
||||
protected final List<StringFilterGroup> pathCallbacks = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Adds callbacks to {@link #isFiltered(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, byte[], StringFilterGroup, FilterContentType, int)}
|
||||
* if any of the groups are found.
|
||||
*/
|
||||
protected final void addPathCallbacks(StringFilterGroup... groups) {
|
||||
pathCallbacks.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.
|
||||
* <p>
|
||||
* 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.
|
||||
* <p>
|
||||
* 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.
|
||||
*/
|
||||
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
|
||||
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
|
||||
if (BaseSettings.DEBUG.get()) {
|
||||
String filterSimpleName = getClass().getSimpleName();
|
||||
if (contentType == FilterContentType.IDENTIFIER) {
|
||||
Logger.printDebug(() -> filterSimpleName + " Filtered identifier: " + identifier);
|
||||
} else {
|
||||
Logger.printDebug(() -> filterSimpleName + " Filtered path: " + path);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for actual filters.
|
||||
*/
|
||||
final class DummyFilter extends Filter { }
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public final class LithoFilterPatch {
|
||||
/**
|
||||
@ -520,9 +146,9 @@ public final class LithoFilterPatch {
|
||||
@SuppressWarnings("unused")
|
||||
public static boolean filter(@Nullable String lithoIdentifier, @NonNull StringBuilder pathBuilder) {
|
||||
try {
|
||||
// It is assumed that protobufBuffer is empty as well in this case.
|
||||
if (pathBuilder.length() == 0)
|
||||
if (pathBuilder.length() == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ByteBuffer protobufBuffer = bufferThreadLocal.get();
|
||||
final byte[] bufferArray;
|
||||
@ -542,10 +168,13 @@ public final class LithoFilterPatch {
|
||||
pathBuilder.toString(), bufferArray);
|
||||
Logger.printDebug(() -> "Searching " + parameter);
|
||||
|
||||
if (parameter.identifier != null) {
|
||||
if (identifierSearchTree.matches(parameter.identifier, parameter)) return true;
|
||||
if (parameter.identifier != null && identifierSearchTree.matches(parameter.identifier, parameter)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathSearchTree.matches(parameter.path, parameter)) {
|
||||
return true;
|
||||
}
|
||||
if (pathSearchTree.matches(parameter.path, parameter)) return true;
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "Litho filter failure", ex);
|
||||
}
|
||||
@ -553,3 +182,8 @@ public final class LithoFilterPatch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for actual filters.
|
||||
*/
|
||||
final class DummyFilter extends Filter { }
|
@ -9,6 +9,11 @@ import androidx.annotation.Nullable;
|
||||
|
||||
import com.google.android.libraries.youtube.rendering.ui.pivotbar.PivotBar;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.Utils;
|
||||
import app.revanced.integrations.youtube.settings.Settings;
|
||||
import app.revanced.integrations.youtube.shared.NavigationBar;
|
||||
@ -16,14 +21,26 @@ import app.revanced.integrations.youtube.shared.PlayerType;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public final class ShortsFilter extends Filter {
|
||||
public static PivotBar pivotBar; // Set by patch.
|
||||
|
||||
public static final Boolean HIDE_SHORTS_NAVIGATION_BAR = Settings.HIDE_SHORTS_NAVIGATION_BAR.get();
|
||||
private final static String REEL_CHANNEL_BAR_PATH = "reel_channel_bar.eml";
|
||||
|
||||
/**
|
||||
* For paid promotion label and subscribe button that appears in the channel bar.
|
||||
*/
|
||||
private final static String REEL_METAPANEL_PATH = "reel_metapanel.eml";
|
||||
|
||||
/**
|
||||
* Tags that appears when opening the Shorts player.
|
||||
*/
|
||||
private static final List<String> REEL_WATCH_FRAGMENT_INIT_PLAYBACK = Arrays.asList("r_fs", "r_ts");
|
||||
|
||||
/**
|
||||
* Vertical padding between the bottom of the screen and the seekbar, when the Shorts navigation bar is hidden.
|
||||
*/
|
||||
public static final int HIDDEN_NAVIGATION_BAR_VERTICAL_HEIGHT = 100;
|
||||
|
||||
private static WeakReference<PivotBar> pivotBarRef = new WeakReference<>(null);
|
||||
|
||||
private final StringFilterGroup shortsCompactFeedVideoPath;
|
||||
private final ByteArrayFilterGroup shortsCompactFeedVideoBuffer;
|
||||
|
||||
@ -241,9 +258,7 @@ public final class ShortsFilter extends Filter {
|
||||
if (matchedGroup == subscribeButton || matchedGroup == joinButton || matchedGroup == paidPromotionButton) {
|
||||
// Selectively filter to avoid false positive filtering of other subscribe/join buttons.
|
||||
if (path.startsWith(REEL_CHANNEL_BAR_PATH) || path.startsWith(REEL_METAPANEL_PATH)) {
|
||||
return super.isFiltered(
|
||||
identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex
|
||||
);
|
||||
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -258,9 +273,7 @@ public final class ShortsFilter extends Filter {
|
||||
// Video action buttons (like, dislike, comment, share, remix) have the same path.
|
||||
if (matchedGroup == actionBar) {
|
||||
if (videoActionButtonGroupList.check(protobufBufferArray).isFiltered()) {
|
||||
return super.isFiltered(
|
||||
identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex
|
||||
);
|
||||
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -268,9 +281,7 @@ public final class ShortsFilter extends Filter {
|
||||
if (matchedGroup == suggestedAction) {
|
||||
// Suggested actions can be at the start or in the middle of a path.
|
||||
if (suggestedActionsGroupList.check(protobufBufferArray).isFiltered()) {
|
||||
return super.isFiltered(
|
||||
identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex
|
||||
);
|
||||
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -343,6 +354,14 @@ public final class ShortsFilter extends Filter {
|
||||
}
|
||||
}
|
||||
|
||||
public static int getSoundButtonSize(int original) {
|
||||
if (Settings.HIDE_SHORTS_SOUND_BUTTON.get()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return original;
|
||||
}
|
||||
|
||||
// region Hide the buttons in older versions of YouTube. New versions use Litho.
|
||||
|
||||
public static void hideLikeButton(final View likeButtonView) {
|
||||
@ -374,17 +393,30 @@ public final class ShortsFilter extends Filter {
|
||||
|
||||
// endregion
|
||||
|
||||
public static void hideNavigationBar() {
|
||||
if (!Settings.HIDE_SHORTS_NAVIGATION_BAR.get()) return;
|
||||
public static void setNavigationBar(PivotBar view) {
|
||||
Logger.printDebug(() -> "Setting navigation bar");
|
||||
pivotBarRef = new WeakReference<>(view);
|
||||
}
|
||||
|
||||
public static void hideNavigationBar(String tag) {
|
||||
if (HIDE_SHORTS_NAVIGATION_BAR) {
|
||||
if (REEL_WATCH_FRAGMENT_INIT_PLAYBACK.contains(tag)) {
|
||||
var pivotBar = pivotBarRef.get();
|
||||
if (pivotBar == null) return;
|
||||
|
||||
Logger.printDebug(() -> "Hiding navbar by setting to GONE");
|
||||
pivotBar.setVisibility(View.GONE);
|
||||
} else {
|
||||
Logger.printDebug(() -> "Ignoring tag: " + tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static View hideNavigationBar(final View navigationBarView) {
|
||||
if (Settings.HIDE_SHORTS_NAVIGATION_BAR.get())
|
||||
return null; // Hides the navigation bar.
|
||||
public static int getNavigationBarHeight(int original) {
|
||||
if (HIDE_SHORTS_NAVIGATION_BAR) {
|
||||
return HIDDEN_NAVIGATION_BAR_VERTICAL_HEIGHT;
|
||||
}
|
||||
|
||||
return navigationBarView;
|
||||
return original;
|
||||
}
|
||||
}
|
||||
|
@ -4,20 +4,32 @@ import static app.revanced.integrations.shared.StringRef.str;
|
||||
|
||||
import android.graphics.Color;
|
||||
|
||||
import app.revanced.integrations.youtube.settings.Settings;
|
||||
import java.util.Arrays;
|
||||
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.Utils;
|
||||
import app.revanced.integrations.youtube.settings.Settings;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public final class SeekbarColorPatch {
|
||||
|
||||
private static final boolean USE_SEEKBAR_CUSTOM_COLOR = Settings.SEEKBAR_CUSTOM_COLOR.get();
|
||||
private static final boolean SEEKBAR_CUSTOM_COLOR_ENABLED = Settings.SEEKBAR_CUSTOM_COLOR.get();
|
||||
|
||||
/**
|
||||
* Default color of the seekbar.
|
||||
*/
|
||||
private static final int ORIGINAL_SEEKBAR_COLOR = 0xFFFF0000;
|
||||
|
||||
/**
|
||||
* Default colors of the gradient seekbar.
|
||||
*/
|
||||
private static final int[] ORIGINAL_SEEKBAR_GRADIENT_COLORS = { 0xFFFF0033, 0xFFFF2791 };
|
||||
|
||||
/**
|
||||
* Default positions of the gradient seekbar.
|
||||
*/
|
||||
private static final float[] ORIGINAL_SEEKBAR_GRADIENT_POSITIONS = { 0.8f, 1.0f };
|
||||
|
||||
/**
|
||||
* Default YouTube seekbar color brightness.
|
||||
*/
|
||||
@ -40,7 +52,7 @@ public final class SeekbarColorPatch {
|
||||
Color.colorToHSV(ORIGINAL_SEEKBAR_COLOR, hsv);
|
||||
ORIGINAL_SEEKBAR_COLOR_BRIGHTNESS = hsv[2];
|
||||
|
||||
if (USE_SEEKBAR_CUSTOM_COLOR) {
|
||||
if (SEEKBAR_CUSTOM_COLOR_ENABLED) {
|
||||
loadCustomSeekbarColor();
|
||||
}
|
||||
}
|
||||
@ -60,6 +72,14 @@ public final class SeekbarColorPatch {
|
||||
return seekbarColor;
|
||||
}
|
||||
|
||||
public static boolean playerSeekbarGradientEnabled(boolean original) {
|
||||
if (original) {
|
||||
Logger.printDebug(() -> "playerSeekbarGradientEnabled original: " + true);
|
||||
if (SEEKBAR_CUSTOM_COLOR_ENABLED) return false;
|
||||
}
|
||||
|
||||
return original;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
@ -74,17 +94,42 @@ public final class SeekbarColorPatch {
|
||||
if (Settings.HIDE_SEEKBAR_THUMBNAIL.get()) {
|
||||
return 0x00000000;
|
||||
}
|
||||
|
||||
return getSeekbarColorValue(ORIGINAL_SEEKBAR_COLOR);
|
||||
}
|
||||
return colorValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static void setLinearGradient(int[] colors, float[] positions) {
|
||||
if (SEEKBAR_CUSTOM_COLOR_ENABLED) {
|
||||
// Most litho usage of linear gradients is hooked here,
|
||||
// so must only change if the values are those for the seekbar.
|
||||
if (Arrays.equals(ORIGINAL_SEEKBAR_GRADIENT_COLORS, colors)
|
||||
&& Arrays.equals(ORIGINAL_SEEKBAR_GRADIENT_POSITIONS, positions)) {
|
||||
Arrays.fill(colors, Settings.HIDE_SEEKBAR_THUMBNAIL.get()
|
||||
? 0x00000000
|
||||
: seekbarColor);
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.printDebug(() -> "Ignoring gradient colors: " + Arrays.toString(colors)
|
||||
+ " positions: " + Arrays.toString(positions));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection point.
|
||||
*
|
||||
* Overrides color when video player seekbar is clicked.
|
||||
*/
|
||||
public static int getVideoPlayerSeekbarClickedColor(int colorValue) {
|
||||
if (!SEEKBAR_CUSTOM_COLOR_ENABLED) {
|
||||
return colorValue;
|
||||
}
|
||||
|
||||
return colorValue == ORIGINAL_SEEKBAR_COLOR
|
||||
? getSeekbarColorValue(ORIGINAL_SEEKBAR_COLOR)
|
||||
: colorValue;
|
||||
@ -96,6 +141,10 @@ public final class SeekbarColorPatch {
|
||||
* Overrides color used for the video player seekbar.
|
||||
*/
|
||||
public static int getVideoPlayerSeekbarColor(int originalColor) {
|
||||
if (!SEEKBAR_CUSTOM_COLOR_ENABLED) {
|
||||
return originalColor;
|
||||
}
|
||||
|
||||
return getSeekbarColorValue(originalColor);
|
||||
}
|
||||
|
||||
@ -105,9 +154,10 @@ public final class SeekbarColorPatch {
|
||||
*/
|
||||
private static int getSeekbarColorValue(int originalColor) {
|
||||
try {
|
||||
if (!USE_SEEKBAR_CUSTOM_COLOR || originalColor == seekbarColor) {
|
||||
if (!SEEKBAR_CUSTOM_COLOR_ENABLED || originalColor == seekbarColor) {
|
||||
return originalColor; // nothing to do
|
||||
}
|
||||
|
||||
final int alphaDifference = Color.alpha(originalColor) - Color.alpha(ORIGINAL_SEEKBAR_COLOR);
|
||||
|
||||
// The seekbar uses the same color but different brightness for different situations.
|
||||
@ -131,11 +181,13 @@ public final class SeekbarColorPatch {
|
||||
}
|
||||
}
|
||||
|
||||
static int clamp(int value, int lower, int upper) {
|
||||
/** @noinspection SameParameterValue */
|
||||
private static int clamp(int value, int lower, int upper) {
|
||||
return Math.max(lower, Math.min(value, upper));
|
||||
}
|
||||
|
||||
static float clamp(float value, float lower, float upper) {
|
||||
/** @noinspection SameParameterValue */
|
||||
private static float clamp(float value, float lower, float upper) {
|
||||
return Math.max(lower, Math.min(value, upper));
|
||||
}
|
||||
}
|
||||
|
@ -44,7 +44,7 @@ public class Requester {
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
jsonBuilder.append(line);
|
||||
jsonBuilder.append("\n");
|
||||
jsonBuilder.append('\n');
|
||||
}
|
||||
return jsonBuilder.toString();
|
||||
}
|
||||
|
@ -585,8 +585,13 @@ public class ReturnYouTubeDislike {
|
||||
public void sendVote(@NonNull Vote vote) {
|
||||
Utils.verifyOnMainThread();
|
||||
Objects.requireNonNull(vote);
|
||||
|
||||
try {
|
||||
if (isShort != PlayerType.getCurrent().isNoneOrHidden()) {
|
||||
PlayerType currentType = PlayerType.getCurrent();
|
||||
if (isShort != currentType.isNoneHiddenOrMinimized()) {
|
||||
Logger.printDebug(() -> "Cannot vote for video: " + videoId
|
||||
+ " as current player type does not match: " + currentType);
|
||||
|
||||
// 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.
|
||||
|
@ -1,5 +1,18 @@
|
||||
package app.revanced.integrations.youtube.settings;
|
||||
|
||||
import static java.lang.Boolean.FALSE;
|
||||
import static java.lang.Boolean.TRUE;
|
||||
import static app.revanced.integrations.shared.settings.Setting.*;
|
||||
import static app.revanced.integrations.youtube.patches.ChangeStartPagePatch.StartPage;
|
||||
import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerHideExpandCloseAvailability;
|
||||
import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType;
|
||||
import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.*;
|
||||
import static app.revanced.integrations.youtube.sponsorblock.objects.CategoryBehaviour.*;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.settings.*;
|
||||
import app.revanced.integrations.shared.settings.preference.SharedPrefCategory;
|
||||
@ -12,18 +25,6 @@ import app.revanced.integrations.youtube.patches.spoof.SpoofAppVersionPatch;
|
||||
import app.revanced.integrations.youtube.patches.spoof.SpoofVideoStreamsPatch;
|
||||
import app.revanced.integrations.youtube.sponsorblock.SponsorBlockSettings;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
import static app.revanced.integrations.shared.settings.Setting.*;
|
||||
import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType;
|
||||
import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_1;
|
||||
import static app.revanced.integrations.youtube.patches.MiniplayerPatch.MiniplayerType.MODERN_3;
|
||||
import static app.revanced.integrations.youtube.sponsorblock.objects.CategoryBehaviour.*;
|
||||
import static java.lang.Boolean.FALSE;
|
||||
import static java.lang.Boolean.TRUE;
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public class Settings extends BaseSettings {
|
||||
// Video
|
||||
@ -130,16 +131,21 @@ public class Settings extends BaseSettings {
|
||||
public static final BooleanSetting DISABLE_LIKE_SUBSCRIBE_GLOW = new BooleanSetting("revanced_disable_like_subscribe_glow", FALSE);
|
||||
public static final BooleanSetting HIDE_AUTOPLAY_BUTTON = new BooleanSetting("revanced_hide_autoplay_button", TRUE, true);
|
||||
public static final BooleanSetting HIDE_CAST_BUTTON = new BooleanSetting("revanced_hide_cast_button", TRUE, true);
|
||||
public static final BooleanSetting HIDE_PLAYER_BUTTONS = new BooleanSetting("revanced_hide_player_buttons", FALSE);
|
||||
public static final BooleanSetting HIDE_PLAYER_BUTTONS = new BooleanSetting("revanced_hide_player_buttons", FALSE, true);
|
||||
public static final BooleanSetting COPY_VIDEO_URL = new BooleanSetting("revanced_copy_video_url", FALSE);
|
||||
public static final BooleanSetting COPY_VIDEO_URL_TIMESTAMP = new BooleanSetting("revanced_copy_video_url_timestamp", TRUE);
|
||||
public static final BooleanSetting PLAYBACK_SPEED_DIALOG_BUTTON = new BooleanSetting("revanced_playback_speed_dialog_button", FALSE);
|
||||
|
||||
// Miniplayer
|
||||
public static final EnumSetting<MiniplayerType> MINIPLAYER_TYPE = new EnumSetting<>("revanced_miniplayer_type", MiniplayerType.ORIGINAL, true);
|
||||
public static final BooleanSetting MINIPLAYER_HIDE_EXPAND_CLOSE = new BooleanSetting("revanced_miniplayer_hide_expand_close", FALSE, true, MINIPLAYER_TYPE.availability(MODERN_1, MODERN_3));
|
||||
private static final Availability MINIPLAYER_ANY_MODERN = MINIPLAYER_TYPE.availability(MODERN_1, MODERN_2, MODERN_3, MODERN_4);
|
||||
public static final BooleanSetting MINIPLAYER_DOUBLE_TAP_ACTION = new BooleanSetting("revanced_miniplayer_double_tap_action", TRUE, true, MINIPLAYER_ANY_MODERN);
|
||||
public static final BooleanSetting MINIPLAYER_DRAG_AND_DROP = new BooleanSetting("revanced_miniplayer_drag_and_drop", TRUE, true, MINIPLAYER_ANY_MODERN);
|
||||
public static final BooleanSetting MINIPLAYER_HIDE_EXPAND_CLOSE = new BooleanSetting("revanced_miniplayer_hide_expand_close", FALSE, true, new MiniplayerHideExpandCloseAvailability());
|
||||
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 BooleanSetting MINIPLAYER_ROUNDED_CORNERS = new BooleanSetting("revanced_miniplayer_rounded_corners", TRUE, true, MINIPLAYER_ANY_MODERN);
|
||||
public static final IntegerSetting MINIPLAYER_WIDTH_DIP = new IntegerSetting("revanced_miniplayer_width_dip", 192, true, MINIPLAYER_ANY_MODERN);
|
||||
public static final IntegerSetting MINIPLAYER_OPACITY = new IntegerSetting("revanced_miniplayer_opacity", 100, true, MINIPLAYER_TYPE.availability(MODERN_1));
|
||||
|
||||
// External downloader
|
||||
@ -188,7 +194,7 @@ public class Settings extends BaseSettings {
|
||||
public static final BooleanSetting HIDE_VIDEO_QUALITY_MENU_FOOTER = new BooleanSetting("revanced_hide_video_quality_menu_footer", FALSE);
|
||||
|
||||
// General layout
|
||||
public static final StringSetting START_PAGE = new StringSetting("revanced_start_page", "");
|
||||
public static final EnumSetting<StartPage> CHANGE_START_PAGE = new EnumSetting<>("revanced_change_start_page", StartPage.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", "17.33.42", true, parent(SPOOF_APP_VERSION));
|
||||
public static final BooleanSetting TABLET_LAYOUT = new BooleanSetting("revanced_tablet_layout", FALSE, true, "revanced_tablet_layout_user_dialog_message");
|
||||
@ -239,12 +245,12 @@ public class Settings extends BaseSettings {
|
||||
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", FALSE);
|
||||
public static final BooleanSetting HIDE_SHORTS_FULL_VIDEO_LINK_LABEL = new BooleanSetting("revanced_hide_shorts_full_video_link_label", FALSE);
|
||||
public static final BooleanSetting HIDE_SHORTS_NAVIGATION_BAR = new BooleanSetting("revanced_hide_shorts_navigation_bar", TRUE, true);
|
||||
public static final BooleanSetting HIDE_SHORTS_NAVIGATION_BAR = new BooleanSetting("revanced_hide_shorts_navigation_bar", FALSE, true);
|
||||
|
||||
// Seekbar
|
||||
public static final BooleanSetting DISABLE_PRECISE_SEEKING_GESTURE = new BooleanSetting("revanced_disable_precise_seeking_gesture", TRUE);
|
||||
public static final BooleanSetting SEEKBAR_TAPPING = new BooleanSetting("revanced_seekbar_tapping", TRUE);
|
||||
public static final BooleanSetting SLIDE_TO_SEEK = new BooleanSetting("revanced_slide_to_seek", FALSE);
|
||||
public static final BooleanSetting SLIDE_TO_SEEK = new BooleanSetting("revanced_slide_to_seek", FALSE, true);
|
||||
public static final BooleanSetting RESTORE_OLD_SEEKBAR_THUMBNAILS = new BooleanSetting("revanced_restore_old_seekbar_thumbnails", 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);
|
||||
|
@ -381,6 +381,8 @@ public class SponsorBlockPreferenceFragment extends PreferenceFragment {
|
||||
|
||||
importExport = new EditTextPreference(context) {
|
||||
protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
|
||||
Utils.setEditTextDialogTheme(builder);
|
||||
|
||||
builder.setNeutralButton(str("revanced_sb_settings_copy"), (dialog, which) -> {
|
||||
Utils.setClipboard(getEditText().getText().toString());
|
||||
});
|
||||
|
@ -8,6 +8,8 @@ import android.view.View;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
@ -156,11 +158,11 @@ public final class NavigationBar {
|
||||
try {
|
||||
String lastEnumName = lastYTNavigationEnumName;
|
||||
|
||||
for (NavigationButton button : NavigationButton.values()) {
|
||||
if (button.ytEnumName.equals(lastEnumName)) {
|
||||
for (NavigationButton buttonType : NavigationButton.values()) {
|
||||
if (buttonType.ytEnumNames.contains(lastEnumName)) {
|
||||
Logger.printDebug(() -> "navigationTabLoaded: " + lastEnumName);
|
||||
viewToButtonMap.put(navigationButtonGroup, button);
|
||||
navigationTabCreatedCallback(button, navigationButtonGroup);
|
||||
viewToButtonMap.put(navigationButtonGroup, buttonType);
|
||||
navigationTabCreatedCallback(buttonType, navigationButtonGroup);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -184,10 +186,10 @@ public final class NavigationBar {
|
||||
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.ytEnumName.equals(lastYTNavigationEnumName)) {
|
||||
if (CREATE.ytEnumNames.contains(lastYTNavigationEnumName)) {
|
||||
navigationTabLoaded(view);
|
||||
} else {
|
||||
lastYTNavigationEnumName = NavigationButton.LIBRARY_YOU.ytEnumName;
|
||||
lastYTNavigationEnumName = NavigationButton.LIBRARY.ytEnumNames.get(0);
|
||||
navigationTabLoaded(view);
|
||||
}
|
||||
}
|
||||
@ -237,44 +239,39 @@ public final class NavigationBar {
|
||||
}
|
||||
|
||||
public enum NavigationButton {
|
||||
HOME("PIVOT_HOME"),
|
||||
SHORTS("TAB_SHORTS"),
|
||||
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"),
|
||||
SUBSCRIPTIONS("PIVOT_SUBSCRIPTIONS"),
|
||||
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"),
|
||||
NOTIFICATIONS("TAB_ACTIVITY", "TAB_ACTIVITY_CAIRO"),
|
||||
/**
|
||||
* Library tab when the user is not logged in.
|
||||
*/
|
||||
LIBRARY_LOGGED_OUT("ACCOUNT_CIRCLE"),
|
||||
/**
|
||||
* User is logged in with incognito mode enabled.
|
||||
*/
|
||||
LIBRARY_INCOGNITO("INCOGNITO_CIRCLE"),
|
||||
/**
|
||||
* Old library tab (pre 'You' layout), only present when version spoofing.
|
||||
*/
|
||||
LIBRARY_OLD_UI("VIDEO_LIBRARY_WHITE"),
|
||||
/**
|
||||
* 'You' library tab that is sometimes momentarily loaded.
|
||||
* When this is loaded, {@link #LIBRARY_YOU} is also present.
|
||||
*
|
||||
* This might be a temporary tab while the user profile photo is loading,
|
||||
* but its exact purpose is not entirely clear.
|
||||
*/
|
||||
LIBRARY_PIVOT_UNKNOWN("PIVOT_LIBRARY"),
|
||||
/**
|
||||
* Modern library tab with 'You' layout.
|
||||
* 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.
|
||||
LIBRARY_YOU("YOU_LIBRARY_DUMMY_PLACEHOLDER_NAME");
|
||||
"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;
|
||||
@ -303,16 +300,10 @@ public final class NavigationBar {
|
||||
/**
|
||||
* YouTube enum name for this tab.
|
||||
*/
|
||||
private final String ytEnumName;
|
||||
private final List<String> ytEnumNames;
|
||||
|
||||
NavigationButton(String ytEnumName) {
|
||||
this.ytEnumName = ytEnumName;
|
||||
}
|
||||
|
||||
public boolean isLibraryOrYouTab() {
|
||||
return this == LIBRARY_YOU || this == LIBRARY_PIVOT_UNKNOWN
|
||||
|| this == LIBRARY_OLD_UI || this == LIBRARY_INCOGNITO
|
||||
|| this == LIBRARY_LOGGED_OUT;
|
||||
NavigationButton(String... ytEnumNames) {
|
||||
this.ytEnumNames = Arrays.asList(ytEnumNames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -21,6 +21,7 @@ import java.util.Objects;
|
||||
import app.revanced.integrations.shared.Logger;
|
||||
import app.revanced.integrations.shared.Utils;
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
public class SegmentCategoryListPreference extends ListPreference {
|
||||
private final SegmentCategory category;
|
||||
private EditText mEditText;
|
||||
@ -45,6 +46,8 @@ public class SegmentCategoryListPreference extends ListPreference {
|
||||
@Override
|
||||
protected void onPrepareDialogBuilder(AlertDialog.Builder builder) {
|
||||
try {
|
||||
Utils.setEditTextDialogTheme(builder);
|
||||
|
||||
Context context = builder.getContext();
|
||||
TableLayout table = new TableLayout(context);
|
||||
table.setOrientation(LinearLayout.HORIZONTAL);
|
||||
|
@ -25,8 +25,8 @@ public class CreateSegmentButtonController {
|
||||
public static void initialize(View youtubeControlsLayout) {
|
||||
try {
|
||||
Logger.printDebug(() -> "initializing new segment button");
|
||||
ImageView imageView = Objects.requireNonNull(youtubeControlsLayout.findViewById(
|
||||
getResourceIdentifier("revanced_sb_create_segment_button", "id")));
|
||||
ImageView imageView = Objects.requireNonNull(Utils.getChildViewByResourceName(
|
||||
youtubeControlsLayout, "revanced_sb_create_segment_button"));
|
||||
imageView.setVisibility(View.GONE);
|
||||
imageView.setOnClickListener(v -> SponsorBlockViewController.toggleNewSegmentLayoutVisibility());
|
||||
|
||||
|
@ -27,8 +27,8 @@ public class VotingButtonController {
|
||||
public static void initialize(View youtubeControlsLayout) {
|
||||
try {
|
||||
Logger.printDebug(() -> "initializing voting button");
|
||||
ImageView imageView = Objects.requireNonNull(youtubeControlsLayout.findViewById(
|
||||
getResourceIdentifier("revanced_sb_voting_button", "id")));
|
||||
ImageView imageView = Objects.requireNonNull(Utils.getChildViewByResourceName(
|
||||
youtubeControlsLayout, "revanced_sb_voting_button"));
|
||||
imageView.setVisibility(View.GONE);
|
||||
imageView.setOnClickListener(v -> SponsorBlockUtils.onVotingClicked(v.getContext()));
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user