chore: Separate extensions by app (#3905)

This commit is contained in:
oSumAtrIX
2024-12-05 12:12:48 +01:00
committed by GitHub
parent 69ec47cbef
commit cc40246e60
314 changed files with 371 additions and 148 deletions

View File

@ -0,0 +1,8 @@
//noinspection GradleDependency
android.compileSdk = 33
dependencies {
compileOnly(project(":extensions:shared:library"))
compileOnly(project(":extensions:youtube:stub"))
compileOnly(libs.annotation)
}

View File

@ -0,0 +1 @@
<manifest/>

View File

@ -0,0 +1,45 @@
package app.revanced.extension.youtube;
import androidx.annotation.NonNull;
import java.nio.charset.StandardCharsets;
public final class ByteTrieSearch extends TrieSearch<byte[]> {
private static final class ByteTrieNode extends TrieNode<byte[]> {
ByteTrieNode() {
super();
}
ByteTrieNode(char nodeCharacterValue) {
super(nodeCharacterValue);
}
@Override
TrieNode<byte[]> 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);
}
}

View File

@ -0,0 +1,29 @@
package app.revanced.extension.youtube
/**
* generic event provider class
*/
class Event<T> {
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)
}
fun removeObserver(observer: (T) -> Unit) {
eventListeners.remove(observer)
}
operator fun invoke(value: T) {
for (observer in eventListeners)
observer.invoke(value)
}
}

View File

@ -0,0 +1,34 @@
package app.revanced.extension.youtube;
import androidx.annotation.NonNull;
/**
* Text pattern searching using a prefix tree (trie).
*/
public final class StringTrieSearch extends TrieSearch<String> {
private static final class StringTrieNode extends TrieNode<String> {
StringTrieNode() {
super();
}
StringTrieNode(char nodeCharacterValue) {
super(nodeCharacterValue);
}
@Override
TrieNode<String> 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);
}
}

View File

@ -0,0 +1,93 @@
package app.revanced.extension.youtube;
import android.app.Activity;
import android.graphics.Color;
import androidx.annotation.Nullable;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
public class ThemeHelper {
@Nullable
private static Integer darkThemeColor, lightThemeColor;
private static int themeValue;
/**
* Injection point.
*/
@SuppressWarnings("unused")
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 void setActivityTheme(Activity activity) {
final var theme = isDarkTheme()
? "Theme.YouTube.Settings.Dark"
: "Theme.YouTube.Settings";
activity.setTheme(Utils.getResourceIdentifier(theme, "style"));
}
/**
* Injection point.
*/
@SuppressWarnings("SameReturnValue")
private static String darkThemeResourceName() {
// Value is changed by Theme patch, if included.
return "@color/yt_black3";
}
/**
* @return The dark theme color as specified by the Theme patch (if included),
* or the dark mode background color unpatched YT uses.
*/
public static int getDarkThemeColor() {
if (darkThemeColor == null) {
darkThemeColor = getColorInt(darkThemeResourceName());
}
return darkThemeColor;
}
/**
* Injection point.
*/
@SuppressWarnings("SameReturnValue")
private static String lightThemeResourceName() {
// Value is changed by Theme patch, if included.
return "@color/yt_white1";
}
/**
* @return The light theme color as specified by the Theme patch (if included),
* or the non dark mode background color unpatched YT uses.
*/
public static int getLightThemeColor() {
if (lightThemeColor == null) {
lightThemeColor = getColorInt(lightThemeResourceName());
}
return lightThemeColor;
}
private static int getColorInt(String colorString) {
if (colorString.startsWith("#")) {
return Color.parseColor(colorString);
}
return Utils.getResourceColor(colorString);
}
public static int getBackgroundColor() {
return isDarkTheme() ? getDarkThemeColor() : getLightThemeColor();
}
public static int getForegroundColor() {
return isDarkTheme() ? getLightThemeColor() : getDarkThemeColor();
}
}

View File

@ -0,0 +1,412 @@
package app.revanced.extension.youtube;
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<T> {
public interface TriePatternMatchedCallback<T> {
/**
* 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<T> {
final T pattern;
final int patternStartIndex;
final int patternLength;
final TriePatternMatchedCallback<T> callback;
TrieCompressedPath(T pattern, int patternStartIndex, int patternLength, TriePatternMatchedCallback<T> callback) {
this.pattern = pattern;
this.patternStartIndex = patternStartIndex;
this.patternLength = patternLength;
this.callback = callback;
}
boolean matches(TrieNode<T> 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<T> {
/**
* 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<T> 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<T>[] children;
/**
* Callbacks for all patterns that end at this node.
*/
@Nullable
private List<TriePatternMatchedCallback<T>> 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<T> 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<T> 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<T> 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<T> child) {
int replacementArraySize = Objects.requireNonNull(children).length;
while (true) {
replacementArraySize += CHILDREN_ARRAY_INCREASE_SIZE_INCREMENT;
//noinspection unchecked
TrieNode<T>[] replacement = new TrieNode[replacementArraySize];
addNodeToArray(replacement, child);
boolean collision = false;
for (TrieNode<T> existingChild : children) {
if (existingChild != null) {
if (!addNodeToArray(replacement, existingChild)) {
collision = true;
break;
}
}
}
if (collision) {
continue;
}
children = replacement;
return;
}
}
private static <T> boolean addNodeToArray(TrieNode<T>[] array, TrieNode<T> 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 <T> boolean matches(final TrieNode<T> startNode, final T searchText,
int searchTextIndex, final int searchTextEndIndex,
final Object callbackParameter) {
TrieNode<T> node = startNode;
int currentMatchLength = 0;
while (true) {
TrieCompressedPath<T> leaf = node.leaf;
if (leaf != null && leaf.matches(startNode, searchText, searchTextEndIndex, searchTextIndex, callbackParameter)) {
return true; // Leaf exists and it matched the search text.
}
List<TriePatternMatchedCallback<T>> endOfPatternCallback = node.endOfPatternCallback;
if (endOfPatternCallback != null) {
final int matchStartIndex = searchTextIndex - currentMatchLength;
for (@Nullable TriePatternMatchedCallback<T> 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<T>[] 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<T> 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<T> child : children) {
if (child != null) {
numberOfPointers += child.estimatedNumberOfPointersUsed();
}
}
}
return numberOfPointers;
}
abstract TrieNode<T> 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<T> root;
/**
* Patterns to match.
*/
private final List<T> patterns = new ArrayList<>();
@SafeVarargs
TrieSearch(@NonNull TrieNode<T> 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<T> callback) {
addPattern(pattern, root.getTextLength(pattern), Objects.requireNonNull(callback));
}
void addPattern(@NonNull T pattern, int patternLength, @Nullable TriePatternMatchedCallback<T> 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<T> getPatterns() {
return Collections.unmodifiableList(patterns);
}
}

View File

@ -0,0 +1,710 @@
package app.revanced.extension.youtube.patches;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.youtube.settings.Settings.*;
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 app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.NavigationBar;
import app.revanced.extension.youtube.shared.PlayerType;
/**
* Alternative YouTube thumbnails.
* <p>
* Can show YouTube provided screen captures of beginning/middle/end of the video.
* (ie: sd1.jpg, sd2.jpg, sd3.jpg).
* <p>
* Or can show crowd-sourced thumbnails provided by DeArrow (<a href="http://dearrow.ajay.app">...</a>).
* <p>
* Or can use DeArrow and fall back to screen captures if DeArrow is not available.
* <p>
* 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.
*/
@SuppressWarnings("unused")
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.
/**
* 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("Invalid DeArrow API URL. Using default");
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 (PlayerType.getCurrent().isMaximizedOrFullscreen()) {
return ALT_THUMBNAIL_PLAYER.get();
}
// Must check second, as search can be from any tab.
if (NavigationBar.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 if not available NULL.
*/
@Nullable
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 null;
}
/**
* 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.
String fallbackUrl = null;
if (option.useStillImages) {
fallbackUrl = buildYouTubeVideoStillURL(decodedUrl, qualityToUse);
}
if (fallbackUrl == null) {
fallbackUrl = decodedUrl.sanitizedUrl;
}
sanitizedReplacementUrl = buildDeArrowThumbnailURL(decodedUrl.videoId, fallbackUrl);
} else if (option.useStillImages) {
includeTracking = true; // Include view tracking parameters if present.
sanitizedReplacementUrl = buildYouTubeVideoStillURL(decodedUrl, qualityToUse);
if (sanitizedReplacementUrl == null) {
return originalUrl; // Still capture is not available. Return the untouched original url.
}
} 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.
* <p>
* 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: " + decodedUrl.sanitizedUrl);
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.
* <p>
* 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).
* <p>
* 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<String, ThumbnailQuality> originalNameToEnum = new HashMap<>();
/**
* Lookup map of alt name to enum. ie: "hq720_1" to {@link #HQ720}.
*/
private static final Map<String, ThumbnailQuality> 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<String, VerifiedQualities> 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 through the extension 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 {
private static final String YOUTUBE_THUMBNAIL_DOMAIN = "https://i.ytimg.com/";
@Nullable
static DecodedThumbnailUrl decodeImageUrl(String url) {
final int urlPathStartIndex = url.indexOf('/', "https://".length()) + 1;
if (urlPathStartIndex <= 0) return null;
final int urlPathEndIndex = url.indexOf('/', urlPathStartIndex);
if (urlPathEndIndex < 0) return null;
final int videoIdStartIndex = url.indexOf('/', urlPathEndIndex) + 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, urlPathStartIndex, urlPathEndIndex, videoIdStartIndex, videoIdEndIndex,
imageSizeStartIndex, imageSizeEndIndex, imageExtensionEndIndex);
}
final String originalFullUrl;
/** Full usable url, but stripped of any tracking information. */
final String sanitizedUrl;
/** Url path, such as 'vi' or 'vi_webp' */
final String urlPath;
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 urlPathStartIndex, int urlPathEndIndex, int videoIdStartIndex, int videoIdEndIndex,
int imageSizeStartIndex, int imageSizeEndIndex, int imageExtensionEndIndex) {
originalFullUrl = fullUrl;
sanitizedUrl = fullUrl.substring(0, imageExtensionEndIndex);
urlPath = fullUrl.substring(urlPathStartIndex, urlPathEndIndex);
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 network response has been observed, despite the alt webp image being a smaller file).
StringBuilder builder = new StringBuilder(originalFullUrl.length() + 2);
// Many different "i.ytimage.com" domains exist such as "i9.ytimg.com",
// but still captures are frequently not available on the other domains (especially newly uploaded videos).
// So always use the primary domain for a higher success rate.
builder.append(YOUTUBE_THUMBNAIL_DOMAIN).append(urlPath).append('/');
builder.append(videoId).append('/');
builder.append(qualityToUse.getAltImageNameToUse());
builder.append('.').append(imageExtension);
if (includeViewTracking) {
builder.append(viewTrackingParameters);
}
return builder.toString();
}
}
}

View File

@ -0,0 +1,11 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class AutoRepeatPatch {
//Used by app.revanced.patches.youtube.layout.autorepeat.patch.AutoRepeatPatch
public static boolean shouldAutoRepeat() {
return Settings.AUTO_REPEAT.get();
}
}

View File

@ -0,0 +1,44 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.PlayerType;
@SuppressWarnings("unused")
public class BackgroundPlaybackPatch {
/**
* Injection point.
*/
public static boolean isBackgroundPlaybackAllowed(boolean original) {
if (original) return true;
// Steps to verify most edge cases (with Shorts background playback set to off):
// 1. Open a regular video
// 2. Minimize app (PIP should appear)
// 3. Reopen app
// 4. Open a Short (without closing the regular video)
// (try opening both Shorts in the video player suggestions AND Shorts from the home feed)
// 5. Minimize the app (PIP should not appear)
// 6. Reopen app
// 7. Close the Short
// 8. Resume playing the regular video
// 9. Minimize the app (PIP should appear)
if (!VideoInformation.lastVideoIdIsShort()) {
return true; // Definitely is not a Short.
}
// TODO: Add better hook.
// Might be a Shorts, or might be a prior regular video on screen again after a Shorts was closed.
// This incorrectly prevents PIP if player is in WATCH_WHILE_MINIMIZED after closing a Shorts,
// But there's no way around this unless an additional hook is added to definitively detect
// the Shorts player is on screen. This use case is unusual anyways so it's not a huge concern.
return !PlayerType.getCurrent().isNoneHiddenOrMinimized();
}
/**
* Injection point.
*/
public static boolean isBackgroundShortsPlaybackAllowed(boolean original) {
return !Settings.DISABLE_SHORTS_BACKGROUND_PLAYBACK.get();
}
}

View File

@ -0,0 +1,46 @@
package app.revanced.extension.youtube.patches;
import static app.revanced.extension.youtube.settings.Settings.BYPASS_IMAGE_REGION_RESTRICTIONS;
import java.util.regex.Pattern;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class BypassImageRegionRestrictionsPatch {
private static final boolean BYPASS_IMAGE_REGION_RESTRICTIONS_ENABLED = BYPASS_IMAGE_REGION_RESTRICTIONS.get();
private static final String REPLACEMENT_IMAGE_DOMAIN = "https://yt4.ggpht.com";
/**
* 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("^https://(yt3|lh[3-6]|play-lh)\\.(ggpht|googleusercontent)\\.com");
/**
* Injection point. Called off the main thread and by multiple threads at the same time.
*
* @param originalUrl Image url for all image urls loaded.
*/
public static String overrideImageURL(String originalUrl) {
try {
if (BYPASS_IMAGE_REGION_RESTRICTIONS_ENABLED) {
String replacement = YOUTUBE_STATIC_IMAGE_DOMAIN_PATTERN
.matcher(originalUrl).replaceFirst(REPLACEMENT_IMAGE_DOMAIN);
if (Settings.DEBUG.get() && !replacement.equals(originalUrl)) {
Logger.printDebug(() -> "Replaced: '" + originalUrl + "' with: '" + replacement + "'");
}
return replacement;
}
} catch (Exception ex) {
Logger.printException(() -> "overrideImageURL failure", ex);
}
return originalUrl;
}
}

View File

@ -0,0 +1,31 @@
package app.revanced.extension.youtube.patches;
import android.net.Uri;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.shared.Logger;
@SuppressWarnings("unused")
public class BypassURLRedirectsPatch {
private static final String YOUTUBE_REDIRECT_PATH = "/redirect";
/**
* Convert the YouTube redirect URI string to the redirect query URI.
*
* @param uri The YouTube redirect URI string.
* @return The redirect query URI.
*/
public static Uri parseRedirectUri(String uri) {
final var parsed = Uri.parse(uri);
if (Settings.BYPASS_URL_REDIRECTS.get() && parsed.getPath().equals(YOUTUBE_REDIRECT_PATH)) {
var query = Uri.parse(Uri.decode(parsed.getQueryParameter("q")));
Logger.printDebug(() -> "Bypassing YouTube redirect URI: " + query);
return query;
}
return parsed;
}
}

View File

@ -0,0 +1,129 @@
package app.revanced.extension.youtube.patches;
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 app.revanced.extension.shared.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 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);
}
}

View File

@ -0,0 +1,84 @@
package app.revanced.extension.youtube.patches;
import static app.revanced.extension.shared.StringRef.str;
import android.app.Activity;
import android.text.Html;
import java.net.InetAddress;
import java.net.UnknownHostException;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class CheckWatchHistoryDomainNameResolutionPatch {
private static final String HISTORY_TRACKING_ENDPOINT = "s.youtube.com";
private static final String SINKHOLE_IPV4 = "0.0.0.0";
private static final String SINKHOLE_IPV6 = "::";
private static boolean domainResolvesToValidIP(String host) {
try {
InetAddress address = InetAddress.getByName(host);
String hostAddress = address.getHostAddress();
if (address.isLoopbackAddress()) {
Logger.printDebug(() -> host + " resolves to localhost");
} else if (SINKHOLE_IPV4.equals(hostAddress) || SINKHOLE_IPV6.equals(hostAddress)) {
Logger.printDebug(() -> host + " resolves to sinkhole ip");
} else {
return true; // Domain is not blocked.
}
} catch (UnknownHostException e) {
Logger.printDebug(() -> host + " failed to resolve");
}
return false;
}
/**
* Injection point.
*
* Checks if s.youtube.com is blacklisted and playback history will fail to work.
*/
public static void checkDnsResolver(Activity context) {
if (!Utils.isNetworkConnected() || !Settings.CHECK_WATCH_HISTORY_DOMAIN_NAME.get()) return;
Utils.runOnBackgroundThread(() -> {
try {
// If the user has a flaky DNS server, or they just lost internet connectivity
// and the isNetworkConnected() check has not detected it yet (it can take a few
// seconds after losing connection), then the history tracking endpoint will
// show a resolving error but it's actually an internet connection problem.
//
// Prevent this false positive by verify youtube.com resolves.
// If youtube.com does not resolve, then it's not a watch history domain resolving error
// because the entire app will not work since no domains are resolving.
if (domainResolvesToValidIP(HISTORY_TRACKING_ENDPOINT)
|| !domainResolvesToValidIP("youtube.com")) {
return;
}
Utils.runOnMainThread(() -> {
var alert = new android.app.AlertDialog.Builder(context)
.setTitle(str("revanced_check_watch_history_domain_name_dialog_title"))
.setMessage(Html.fromHtml(str("revanced_check_watch_history_domain_name_dialog_message")))
.setIconAttribute(android.R.attr.alertDialogIcon)
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
dialog.dismiss();
}).setNegativeButton(str("revanced_check_watch_history_domain_name_dialog_ignore"), (dialog, which) -> {
Settings.CHECK_WATCH_HISTORY_DOMAIN_NAME.save(false);
dialog.dismiss();
}).create();
Utils.showDialog(context, alert, false, null);
});
} catch (Exception ex) {
Logger.printException(() -> "checkDnsResolver failure", ex);
}
});
}
}

View File

@ -0,0 +1,47 @@
package app.revanced.extension.youtube.patches;
import static app.revanced.extension.shared.StringRef.str;
import android.os.Build;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
public class CopyVideoUrlPatch {
public static void copyUrl(boolean withTimestamp) {
try {
StringBuilder builder = new StringBuilder("https://youtu.be/");
builder.append(VideoInformation.getVideoId());
final long currentVideoTimeInSeconds = VideoInformation.getVideoTime() / 1000;
if (withTimestamp && currentVideoTimeInSeconds > 0) {
final long hour = currentVideoTimeInSeconds / (60 * 60);
final long minute = (currentVideoTimeInSeconds / 60) % 60;
final long second = currentVideoTimeInSeconds % 60;
builder.append("?t=");
if (hour > 0) {
builder.append(hour).append("h");
}
if (minute > 0) {
builder.append(minute).append("m");
}
if (second > 0) {
builder.append(second).append("s");
}
}
Utils.setClipboard(builder.toString());
// 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 (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2 || (withTimestamp && currentVideoTimeInSeconds > 0)) {
Utils.showToastShort(withTimestamp && currentVideoTimeInSeconds > 0
? str("revanced_share_copy_url_timestamp_success")
: str("revanced_share_copy_url_success"));
}
} catch (Exception e) {
Logger.printException(() -> "Failed to generate video url", e);
}
}
}

View File

@ -0,0 +1,33 @@
package app.revanced.extension.youtube.patches;
import static app.revanced.extension.shared.StringRef.str;
import android.widget.ImageView;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class CustomPlayerOverlayOpacityPatch {
private static final int PLAYER_OVERLAY_OPACITY_LEVEL;
static {
int opacity = Settings.PLAYER_OVERLAY_OPACITY.get();
if (opacity < 0 || opacity > 100) {
Utils.showToastLong(str("revanced_player_overlay_opacity_invalid_toast"));
Settings.PLAYER_OVERLAY_OPACITY.resetToDefault();
opacity = Settings.PLAYER_OVERLAY_OPACITY.defaultValue;
}
PLAYER_OVERLAY_OPACITY_LEVEL = (opacity * 255) / 100;
}
/**
* Injection point.
*/
public static void changeOpacity(ImageView imageView) {
imageView.setImageAlpha(PLAYER_OVERLAY_OPACITY_LEVEL);
}
}

View File

@ -0,0 +1,20 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.PlayerType;
@SuppressWarnings("unused")
public class DisableAutoCaptionsPatch {
/**
* Used by injected code. Do not delete.
*/
public static boolean captionsButtonDisabled;
public static boolean autoCaptionsEnabled() {
return Settings.AUTO_CAPTIONS.get()
// Do not use auto captions for Shorts.
&& !PlayerType.getCurrent().isNoneHiddenOrSlidingMinimized();
}
}

View File

@ -0,0 +1,25 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
/** @noinspection unused*/
public final class DisableFullscreenAmbientModePatch {
private static final boolean DISABLE_FULLSCREEN_AMBIENT_MODE = Settings.DISABLE_FULLSCREEN_AMBIENT_MODE.get();
/**
* Constant found in: androidx.window.embedding.DividerAttributes
*/
private static final int DIVIDER_ATTRIBUTES_COLOR_SYSTEM_DEFAULT = -16777216;
/**
* Injection point.
*/
public static int getFullScreenBackgroundColor(int originalColor) {
if (DISABLE_FULLSCREEN_AMBIENT_MODE) {
return DIVIDER_ATTRIBUTES_COLOR_SYSTEM_DEFAULT;
}
return originalColor;
}
}

View File

@ -0,0 +1,11 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class DisablePlayerPopupPanelsPatch {
//Used by app.revanced.patches.youtube.layout.playerpopuppanels.patch.PlayerPopupPanelsPatch
public static boolean disablePlayerPopupPanels() {
return Settings.PLAYER_POPUP_PANELS.get();
}
}

View File

@ -0,0 +1,10 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class DisablePreciseSeekingGesturePatch {
public static boolean isGestureDisabled() {
return Settings.DISABLE_PRECISE_SEEKING_GESTURE.get();
}
}

View File

@ -0,0 +1,14 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
/** @noinspection unused*/
public class DisableResumingStartupShortsPlayerPatch {
/**
* Injection point.
*/
public static boolean disableResumingStartupShortsPlayer() {
return Settings.DISABLE_RESUMING_SHORTS_PLAYER.get();
}
}

View File

@ -0,0 +1,13 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class DisableRollingNumberAnimationsPatch {
/**
* Injection point.
*/
public static boolean disableRollingNumberAnimations() {
return Settings.DISABLE_ROLLING_NUMBER_ANIMATIONS.get();
}
}

View File

@ -0,0 +1,24 @@
package app.revanced.extension.youtube.patches;
import android.annotation.SuppressLint;
import android.widget.ImageView;
import app.revanced.extension.youtube.settings.Settings;
/** @noinspection unused*/
public final class DisableSuggestedVideoEndScreenPatch {
@SuppressLint("StaticFieldLeak")
private static ImageView lastView;
public static void closeEndScreen(final ImageView imageView) {
if (!Settings.DISABLE_SUGGESTED_VIDEO_END_SCREEN.get()) return;
// Prevent adding the listener multiple times.
if (lastView == imageView) return;
lastView = imageView;
imageView.getViewTreeObserver().addOnGlobalLayoutListener(() -> {
if (imageView.isShown()) imageView.callOnClick();
});
}
}

View File

@ -0,0 +1,103 @@
package app.revanced.extension.youtube.patches;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import androidx.annotation.NonNull;
import java.lang.ref.WeakReference;
import java.util.Objects;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.StringRef;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class DownloadsPatch {
private static WeakReference<Activity> activityRef = new WeakReference<>(null);
/**
* Injection point.
*/
public static void activityCreated(Activity mainActivity) {
activityRef = new WeakReference<>(mainActivity);
}
/**
* 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 inAppDownloadButtonOnClick(@NonNull String videoId) {
try {
if (!Settings.EXTERNAL_DOWNLOADER_ACTION_BUTTON.get()) {
return false;
}
// If possible, use the main activity as the context.
// Otherwise fall back on using the application context.
Context context = activityRef.get();
boolean isActivityContext = true;
if (context == null) {
// Utils context is the application context, and not an activity context.
context = Utils.getContext();
isActivityContext = false;
}
launchExternalDownloader(videoId, context, isActivityContext);
return true;
} catch (Exception ex) {
Logger.printException(() -> "inAppDownloadButtonOnClick failure", ex);
}
return false;
}
/**
* @param isActivityContext If the context parameter is for an Activity. If this is false, then
* the downloader is opened as a new task (which forces YT to minimize).
*/
public static void launchExternalDownloader(@NonNull String videoId,
@NonNull Context context, boolean isActivityContext) {
try {
Objects.requireNonNull(videoId);
Logger.printDebug(() -> "Launching external downloader with context: " + context);
// Trim string to avoid any accidental whitespace.
var downloaderPackageName = Settings.EXTERNAL_DOWNLOADER_PACKAGE_NAME.get().trim();
boolean packageEnabled = false;
try {
packageEnabled = context.getPackageManager().getApplicationInfo(downloaderPackageName, 0).enabled;
} catch (PackageManager.NameNotFoundException error) {
Logger.printDebug(() -> "External downloader could not be found: " + error);
}
// If the package is not installed, show the toast
if (!packageEnabled) {
Utils.showToastLong(StringRef.str("revanced_external_downloader_not_installed_warning", downloaderPackageName));
return;
}
String content = "https://youtu.be/" + videoId;
Intent intent = new Intent("android.intent.action.SEND");
intent.setType("text/plain");
intent.setPackage(downloaderPackageName);
intent.putExtra("android.intent.extra.TEXT", content);
if (!isActivityContext) {
Logger.printDebug(() -> "Using new task intent");
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
context.startActivity(intent);
} catch (Exception ex) {
Logger.printException(() -> "launchExternalDownloader failure", ex);
}
}
}

View File

@ -0,0 +1,70 @@
package app.revanced.extension.youtube.patches;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.settings.BaseSettings;
@SuppressWarnings("unused")
public final class EnableDebuggingPatch {
private static final ConcurrentMap<Long, Boolean> featureFlags
= new ConcurrentHashMap<>(300, 0.75f, 1);
/**
* Injection point.
*/
public static boolean isBooleanFeatureFlagEnabled(boolean value, long flag) {
if (value && BaseSettings.DEBUG.get()) {
if (featureFlags.putIfAbsent(flag, true) == null) {
Logger.printDebug(() -> "boolean feature is enabled: " + flag);
}
}
return value;
}
/**
* Injection point.
*/
public static double isDoubleFeatureFlagEnabled(double value, long flag, double defaultValue) {
if (defaultValue != value && BaseSettings.DEBUG.get()) {
if (featureFlags.putIfAbsent(flag, true) == null) {
// Align the log outputs to make post processing easier.
Logger.printDebug(() -> " double feature is enabled: " + flag
+ " value: " + value + (defaultValue == 0 ? "" : " default: " + defaultValue));
}
}
return value;
}
/**
* Injection point.
*/
public static long isLongFeatureFlagEnabled(long value, long flag, long defaultValue) {
if (defaultValue != value && BaseSettings.DEBUG.get()) {
if (featureFlags.putIfAbsent(flag, true) == null) {
Logger.printDebug(() -> " long feature is enabled: " + flag
+ " value: " + value + (defaultValue == 0 ? "" : " default: " + defaultValue));
}
}
return value;
}
/**
* Injection point.
*/
public static String isStringFeatureFlagEnabled(String value, long flag, String defaultValue) {
if (BaseSettings.DEBUG.get() && !defaultValue.equals(value)) {
if (featureFlags.putIfAbsent(flag, true) == null) {
Logger.printDebug(() -> " string feature is enabled: " + flag
+ " value: " + value + (defaultValue.isEmpty() ? "" : " default: " + defaultValue));
}
}
return value;
}
}

View File

@ -0,0 +1,44 @@
package app.revanced.extension.youtube.patches;
import android.app.Activity;
import app.revanced.extension.shared.Logger;
@SuppressWarnings("unused")
public class FixBackToExitGesturePatch {
/**
* State whether the scroll position reaches the top.
*/
public static boolean isTopView = false;
/**
* Handle the event after clicking the back button.
*
* @param activity The activity, the app is launched with to finish.
*/
public static void onBackPressed(Activity activity) {
if (!isTopView) return;
Logger.printDebug(() -> "Activity is closed");
activity.finish();
}
/**
* Handle the event when the homepage list of views is being scrolled.
*/
public static void onScrollingViews() {
Logger.printDebug(() -> "Views are scrolling");
isTopView = false;
}
/**
* Handle the event when the homepage list of views reached the top.
*/
public static void onTopView() {
Logger.printDebug(() -> "Scrolling reached the top");
isTopView = true;
}
}

View File

@ -0,0 +1,12 @@
package app.revanced.extension.youtube.patches;
import android.view.View;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class FullscreenPanelsRemoverPatch {
public static int getFullscreenPanelsVisibility() {
return Settings.HIDE_FULLSCREEN_PANELS.get() ? View.GONE : View.VISIBLE;
}
}

View File

@ -0,0 +1,14 @@
package app.revanced.extension.youtube.patches;
import android.view.View;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class HideEndscreenCardsPatch {
//Used by app.revanced.patches.youtube.layout.hideendscreencards.bytecode.patch.HideEndscreenCardsPatch
public static void hideEndscreen(View view) {
if (!Settings.HIDE_ENDSCREEN_CARDS.get()) return;
view.setVisibility(View.GONE);
}
}

View File

@ -0,0 +1,13 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class HideGetPremiumPatch {
/**
* Injection point.
*/
public static boolean hideGetPremiumView() {
return Settings.HIDE_GET_PREMIUM.get();
}
}

View File

@ -0,0 +1,17 @@
package app.revanced.extension.youtube.patches;
import android.view.View;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class HideInfoCardsPatch {
public static void hideInfoCardsIncognito(View view) {
if (!Settings.HIDE_INFO_CARDS.get()) return;
view.setVisibility(View.GONE);
}
public static boolean hideInfoCardsMethodCall() {
return Settings.HIDE_INFO_CARDS.get();
}
}

View File

@ -0,0 +1,72 @@
package app.revanced.extension.youtube.patches;
import android.view.View;
import android.widget.ImageView;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class HidePlayerOverlayButtonsPatch {
private static final boolean HIDE_AUTOPLAY_BUTTON_ENABLED = Settings.HIDE_AUTOPLAY_BUTTON.get();
/**
* Injection point.
*/
public static boolean hideAutoPlayButton() {
return HIDE_AUTOPLAY_BUTTON_ENABLED;
}
/**
* Injection point.
*/
public static int getCastButtonOverrideV2(int original) {
return Settings.HIDE_CAST_BUTTON.get() ? View.GONE : original;
}
/**
* Injection point.
*/
public static void hideCaptionsButton(ImageView imageView) {
imageView.setVisibility(Settings.HIDE_CAPTIONS_BUTTON.get() ? ImageView.GONE : ImageView.VISIBLE);
}
private static final boolean HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS_ENABLED
= Settings.HIDE_PLAYER_PREVIOUS_NEXT_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 void hidePreviousNextButtons(View parentView) {
if (!HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS_ENABLED) {
return;
}
// 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);
}
}

View File

@ -0,0 +1,10 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class HideSeekbarPatch {
public static boolean hideSeekbar() {
return Settings.HIDE_SEEKBAR.get();
}
}

View File

@ -0,0 +1,10 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class HideTimestampPatch {
public static boolean hideTimestamp() {
return Settings.HIDE_TIMESTAMP.get();
}
}

View File

@ -0,0 +1,345 @@
package app.revanced.extension.youtube.patches;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.youtube.patches.MiniplayerPatch.MiniplayerType.*;
import static app.revanced.extension.youtube.patches.VersionCheckPatch.*;
import android.util.DisplayMetrics;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings({"unused", "SpellCheckingInspection"})
public final class MiniplayerPatch {
/**
* Mini player type. Null fields indicates to use the original un-patched value.
*/
public enum MiniplayerType {
/**
* Disabled. When swiped down the miniplayer is immediately closed.
* Only available with 19.43+
*/
DISABLED(false, null),
/** Unmodified type, and same as un-patched. */
ORIGINAL(null, null),
/**
* Exactly the same as MINIMAL and only here for migration of user settings.
* Eventually this should be deleted.
*/
@Deprecated
PHONE(false, null),
MINIMAL(false, null),
TABLET(true, null),
MODERN_1(null, 1),
MODERN_2(null, 2),
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.
*/
@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;
}
}
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.
*/
private static final int MODERN_OVERLAY_SUBTITLE_TEXT
= Utils.getResourceIdentifier("modern_miniplayer_subtitle_text", "id");
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 =
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();
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();
private static final boolean MINIPLAYER_HORIZONTAL_DRAG_ENABLED =
DRAG_AND_DROP_ENABLED && Settings.MINIPLAYER_HORIZONTAL_DRAG.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 MiniplayerHorizontalDragAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
return Settings.MINIPLAYER_TYPE.get().isModern() && Settings.MINIPLAYER_DRAG_AND_DROP.get();
}
}
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();
if (opacity < 0 || opacity > 100) {
Utils.showToastLong(str("revanced_miniplayer_opacity_invalid_toast"));
Settings.MINIPLAYER_OPACITY.resetToDefault();
opacity = Settings.MINIPLAYER_OPACITY.defaultValue;
}
OPACITY_LEVEL = (opacity * 255) / 100;
}
/**
* Injection point.
*
* Enables a handler that immediately closes the miniplayer when the video is minimized,
* effectively disabling the miniplayer.
*/
public static boolean getMiniplayerOnCloseHandler(boolean original) {
return CURRENT_TYPE == ORIGINAL
? original
: CURRENT_TYPE == DISABLED;
}
/**
* 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 getModernFeatureFlagsActiveOverride(boolean original) {
if (CURRENT_TYPE == ORIGINAL) {
return original;
}
return CURRENT_TYPE.isModern();
}
/**
* Injection point.
*/
public static boolean enableMiniplayerDoubleTapAction(boolean original) {
if (CURRENT_TYPE == ORIGINAL) {
return original;
}
return DOUBLE_TAP_ACTION_ENABLED;
}
/**
* Injection point.
*/
public static boolean enableMiniplayerDragAndDrop(boolean original) {
if (CURRENT_TYPE == ORIGINAL) {
return original;
}
return DRAG_AND_DROP_ENABLED;
}
/**
* Injection point.
*/
public static boolean setRoundedCorners(boolean original) {
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 boolean setHorizontalDrag(boolean original) {
if (CURRENT_TYPE.isModern()) {
return MINIPLAYER_HORIZONTAL_DRAG_ENABLED;
}
return original;
}
/**
* 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 void hideMiniplayerSubTexts(View 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) {
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);
if (subtitleText != null) {
subtitleText.setVisibility(View.GONE);
Logger.printDebug(() -> "Modern overlay subtitle view set to hidden");
}
}
}
} catch (Exception ex) {
Logger.printException(() -> "playerOverlayGroupCreated failure", ex);
}
}
}

View File

@ -0,0 +1,51 @@
package app.revanced.extension.youtube.patches;
import static app.revanced.extension.shared.Utils.hideViewUnderCondition;
import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
import android.view.View;
import java.util.EnumMap;
import java.util.Map;
import android.widget.TextView;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class NavigationButtonsPatch {
private static final Map<NavigationButton, Boolean> shouldHideMap = new EnumMap<>(NavigationButton.class) {
{
put(NavigationButton.HOME, Settings.HIDE_HOME_BUTTON.get());
put(NavigationButton.CREATE, Settings.HIDE_CREATE_BUTTON.get());
put(NavigationButton.SHORTS, Settings.HIDE_SHORTS_BUTTON.get());
put(NavigationButton.SUBSCRIPTIONS, Settings.HIDE_SUBSCRIPTIONS_BUTTON.get());
}
};
private static final boolean SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON
= Settings.SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON.get();
/**
* Injection point.
*/
public static boolean switchCreateWithNotificationButton() {
return SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON;
}
/**
* Injection point.
*/
public static void navigationTabCreated(NavigationButton button, View tabView) {
if (Boolean.TRUE.equals(shouldHideMap.get(button))) {
tabView.setVisibility(View.GONE);
}
}
/**
* Injection point.
*/
public static void hideNavigationButtonLabels(TextView navigationLabelsView) {
hideViewUnderCondition(Settings.HIDE_NAVIGATION_BUTTON_LABELS, navigationLabelsView);
}
}

View File

@ -0,0 +1,18 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class OpenLinksExternallyPatch {
/**
* Return the intent to open links with. If empty, the link will be opened with the default browser.
*
* @param originalIntent The original intent to open links with.
* @return The intent to open links with. Empty means the link will be opened with the default browser.
*/
public static String getIntent(String originalIntent) {
if (Settings.EXTERNAL_BROWSER.get()) return "";
return originalIntent;
}
}

View File

@ -0,0 +1,52 @@
package app.revanced.extension.youtube.patches;
import android.view.View;
import android.view.ViewTreeObserver;
import android.widget.ImageView;
import app.revanced.extension.shared.Logger;
@SuppressWarnings("unused")
public class PlayerControlsPatch {
/**
* Injection point.
*/
public static void setFullscreenCloseButton(ImageView imageButton) {
// Add a global listener, since the protected method
// View#onVisibilityChanged() does not have any call backs.
imageButton.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
int lastVisibility = View.VISIBLE;
@Override
public void onGlobalLayout() {
try {
final int visibility = imageButton.getVisibility();
if (lastVisibility != visibility) {
lastVisibility = visibility;
Logger.printDebug(() -> "fullscreen button visibility: "
+ (visibility == View.VISIBLE ? "VISIBLE" :
visibility == View.GONE ? "GONE" : "INVISIBLE"));
fullscreenButtonVisibilityChanged(visibility == View.VISIBLE);
}
} catch (Exception ex) {
Logger.printDebug(() -> "OnGlobalLayoutListener failure", ex);
}
}
});
}
// noinspection EmptyMethod
public static void fullscreenButtonVisibilityChanged(boolean isVisible) {
// Code added during patching.
}
/**
* Injection point.
*/
public static String getPlayerTopControlsLayoutResourceName(String original) {
return "default";
}
}

View File

@ -0,0 +1,15 @@
package app.revanced.extension.youtube.patches;
import android.view.ViewGroup;
import app.revanced.extension.youtube.shared.PlayerOverlays;
@SuppressWarnings("unused")
public class PlayerOverlaysHookPatch {
/**
* Injection point.
*/
public static void playerOverlayInflated(ViewGroup group) {
PlayerOverlays.attach(group);
}
}

View File

@ -0,0 +1,27 @@
package app.revanced.extension.youtube.patches;
import androidx.annotation.Nullable;
import app.revanced.extension.youtube.shared.PlayerType;
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());
}
}

View File

@ -0,0 +1,17 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class RemoveTrackingQueryParameterPatch {
private static final String NEW_TRACKING_PARAMETER_REGEX = ".si=.+";
private static final String OLD_TRACKING_PARAMETER_REGEX = ".feature=.+";
public static String sanitize(String url) {
if (!Settings.REMOVE_TRACKING_QUERY_PARAMETER.get()) return url;
return url
.replaceAll(NEW_TRACKING_PARAMETER_REGEX, "")
.replaceAll(OLD_TRACKING_PARAMETER_REGEX, "");
}
}

View File

@ -0,0 +1,19 @@
package app.revanced.extension.youtube.patches;
import android.app.AlertDialog;
import app.revanced.extension.youtube.settings.Settings;
/** @noinspection unused*/
public class RemoveViewerDiscretionDialogPatch {
public static void confirmDialog(AlertDialog dialog) {
if (!Settings.REMOVE_VIEWER_DISCRETION_DIALOG.get()) {
// Since the patch replaces the AlertDialog#show() method, we need to call the original method here.
dialog.show();
return;
}
final var button = dialog.getButton(AlertDialog.BUTTON_POSITIVE);
button.setSoundEffectsEnabled(false);
button.performClick();
}
}

View File

@ -0,0 +1,644 @@
package app.revanced.extension.youtube.patches;
import static app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike.Vote;
import android.graphics.Rect;
import android.graphics.drawable.ShapeDrawable;
import android.os.Build;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned;
import android.view.View;
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.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.patches.components.ReturnYouTubeDislikeFilterPatch;
import app.revanced.extension.youtube.patches.spoof.SpoofAppVersionPatch;
import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike;
import app.revanced.extension.youtube.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.PlayerType;
/**
* 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 =
SpoofAppVersionPatch.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(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;
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);
}
/**
* 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;
}
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;
}
// 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())) {
// 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<WeakReference<TextView>> shortsTextViewRefs = new ArrayList<>();
private static void clearRemovedShortsTextViews() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { // YouTube requires Android N or greater
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<TextView> 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);
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) {
if (lastLithoShortsVideoData != null) {
lithoShortsShouldUseCurrentData = true;
}
}
return;
}
}
Logger.printException(() -> "Unknown vote type: " + vote);
} catch (Exception ex) {
Logger.printException(() -> "sendVote failure", ex);
}
}
}

View File

@ -0,0 +1,10 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class SeekbarTappingPatch {
public static boolean seekbarTappingEnabled() {
return Settings.SEEKBAR_TAPPING.get();
}
}

View File

@ -0,0 +1,32 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class SeekbarThumbnailsPatch {
public static final class SeekbarThumbnailsHighQualityAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
return VersionCheckPatch.IS_19_17_OR_GREATER || !Settings.RESTORE_OLD_SEEKBAR_THUMBNAILS.get();
}
}
private static final boolean SEEKBAR_THUMBNAILS_HIGH_QUALITY_ENABLED
= Settings.SEEKBAR_THUMBNAILS_HIGH_QUALITY.get();
/**
* Injection point.
*/
public static boolean useHighQualityFullscreenThumbnails() {
return SEEKBAR_THUMBNAILS_HIGH_QUALITY_ENABLED;
}
/**
* Injection point.
*/
public static boolean useFullscreenSeekbarThumbnails() {
return !Settings.RESTORE_OLD_SEEKBAR_THUMBNAILS.get();
}
}

View File

@ -0,0 +1,119 @@
package app.revanced.extension.youtube.patches;
import android.app.Activity;
import android.os.Build;
import androidx.annotation.RequiresApi;
import java.lang.ref.WeakReference;
import java.util.Objects;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class ShortsAutoplayPatch {
private enum ShortsLoopBehavior {
UNKNOWN,
/**
* Repeat the same Short forever!
*/
REPEAT,
/**
* Play once, then advanced to the next Short.
*/
SINGLE_PLAY,
/**
* Pause playback after 1 play.
*/
END_SCREEN;
static void setYTEnumValue(Enum<?> ytBehavior) {
for (ShortsLoopBehavior rvBehavior : values()) {
if (ytBehavior.name().endsWith(rvBehavior.name())) {
rvBehavior.ytEnumValue = ytBehavior;
Logger.printDebug(() -> rvBehavior + " set to YT enum: " + ytBehavior.name());
return;
}
}
Logger.printException(() -> "Unknown Shorts loop behavior: " + ytBehavior.name());
}
/**
* YouTube enum value of the obfuscated enum type.
*/
private Enum<?> ytEnumValue;
}
private static WeakReference<Activity> mainActivityRef = new WeakReference<>(null);
public static void setMainActivity(Activity activity) {
mainActivityRef = new WeakReference<>(activity);
}
/**
* @return If the app is currently in background PiP mode.
*/
@RequiresApi(api = Build.VERSION_CODES.N)
private static boolean isAppInBackgroundPiPMode() {
Activity activity = mainActivityRef.get();
return activity != null && activity.isInPictureInPictureMode();
}
/**
* Injection point.
*/
public static void setYTShortsRepeatEnum(Enum<?> ytEnum) {
try {
for (Enum<?> ytBehavior : Objects.requireNonNull(ytEnum.getClass().getEnumConstants())) {
ShortsLoopBehavior.setYTEnumValue(ytBehavior);
}
} catch (Exception ex) {
Logger.printException(() -> "setYTShortsRepeatEnum failure", ex);
}
}
/**
* Injection point.
*/
@RequiresApi(api = Build.VERSION_CODES.N)
public static Enum<?> changeShortsRepeatBehavior(Enum<?> original) {
try {
final boolean autoplay;
if (isAppInBackgroundPiPMode()) {
if (!VersionCheckPatch.IS_19_34_OR_GREATER) {
// 19.34+ is required to set background play behavior.
Logger.printDebug(() -> "PiP Shorts not supported, using original repeat behavior");
return original;
}
autoplay = Settings.SHORTS_AUTOPLAY_BACKGROUND.get();
} else {
autoplay = Settings.SHORTS_AUTOPLAY.get();
}
final ShortsLoopBehavior behavior = autoplay
? ShortsLoopBehavior.SINGLE_PLAY
: ShortsLoopBehavior.REPEAT;
if (behavior.ytEnumValue != null) {
Logger.printDebug(() -> behavior.ytEnumValue == original
? "Changing Shorts repeat behavior from: " + original.name() + " to: " + behavior.ytEnumValue
: "Behavior setting is same as original. Using original: " + original.name()
);
return behavior.ytEnumValue;
}
} catch (Exception ex) {
Logger.printException(() -> "changeShortsRepeatState failure", ex);
}
return original;
}
}

View File

@ -0,0 +1,14 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class SlideToSeekPatch {
private static final boolean SLIDE_TO_SEEK_DISABLED = !Settings.SLIDE_TO_SEEK.get();
public static boolean isSlideToSeekDisabled(boolean isDisabled) {
if (!isDisabled) return false;
return SLIDE_TO_SEEK_DISABLED;
}
}

View File

@ -0,0 +1,16 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class TabletLayoutPatch {
private static final boolean TABLET_LAYOUT_ENABLED = Settings.TABLET_LAYOUT.get();
/**
* Injection point.
*/
public static boolean getTabletLayoutEnabled() {
return TABLET_LAYOUT_ENABLED;
}
}

View File

@ -0,0 +1,12 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.shared.Utils;
public class VersionCheckPatch {
public static final boolean IS_19_17_OR_GREATER = Utils.getAppVersionName().compareTo("19.17.00") >= 0;
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;
public static final boolean IS_19_34_OR_GREATER = Utils.getAppVersionName().compareTo("19.34.00") >= 0;
}

View File

@ -0,0 +1,14 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class VideoAdsPatch {
// Used by app.revanced.patches.youtube.ad.general.video.patch.VideoAdsPatch
// depends on Whitelist patch (still needs to be written)
public static boolean shouldShowAds() {
return !Settings.HIDE_VIDEO_ADS.get(); // TODO && Whitelist.shouldShowAds();
}
}

View File

@ -0,0 +1,357 @@
package app.revanced.extension.youtube.patches;
import androidx.annotation.NonNull;
import java.lang.ref.WeakReference;
import java.util.Objects;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.shared.VideoState;
/**
* Hooking class for the current playing video.
* @noinspection unused
*/
public final class VideoInformation {
public interface PlaybackController {
// Methods are added to YT classes during patching.
boolean seekTo(long videoTime);
void seekToRelative(long videoTimeOffset);
}
private static final float DEFAULT_YOUTUBE_PLAYBACK_SPEED = 1.0f;
/**
* Prefix present in all Short player parameters signature.
*/
private static final String SHORTS_PLAYER_PARAMETERS = "8AEB";
private static WeakReference<PlaybackController> playerControllerRef = new WeakReference<>(null);
private static WeakReference<PlaybackController> mdxPlayerDirectorRef = new WeakReference<>(null);
@NonNull
private static String videoId = "";
private static long videoLength = 0;
private static long videoTime = -1;
@NonNull
private static volatile String playerResponseVideoId = "";
private static volatile boolean playerResponseVideoIdIsShort;
private static volatile boolean videoIdIsShort;
/**
* The current playback speed
*/
private static float playbackSpeed = DEFAULT_YOUTUBE_PLAYBACK_SPEED;
/**
* Injection point.
*
* @param playerController player controller object.
*/
public static void initialize(@NonNull PlaybackController playerController) {
try {
playerControllerRef = new WeakReference<>(Objects.requireNonNull(playerController));
videoTime = -1;
videoLength = 0;
playbackSpeed = DEFAULT_YOUTUBE_PLAYBACK_SPEED;
} catch (Exception ex) {
Logger.printException(() -> "Failed to initialize", ex);
}
}
/**
* Injection point.
*
* @param mdxPlayerDirector MDX player director object (casting mode).
*/
public static void initializeMdx(@NonNull PlaybackController mdxPlayerDirector) {
try {
mdxPlayerDirectorRef = new WeakReference<>(Objects.requireNonNull(mdxPlayerDirector));
} catch (Exception ex) {
Logger.printException(() -> "Failed to initialize MDX", ex);
}
}
/**
* Injection point.
*
* @param newlyLoadedVideoId id of the current video
*/
public static void setVideoId(@NonNull String newlyLoadedVideoId) {
if (!videoId.equals(newlyLoadedVideoId)) {
Logger.printDebug(() -> "New video id: " + newlyLoadedVideoId);
videoId = newlyLoadedVideoId;
}
}
/**
* @return If the player parameters are for a Short.
*/
public static boolean playerParametersAreShort(@NonNull String parameters) {
return parameters.startsWith(SHORTS_PLAYER_PARAMETERS);
}
/**
* Injection point.
*/
public static String newPlayerResponseSignature(@NonNull String signature, String videoId, boolean isShortAndOpeningOrPlaying) {
final boolean isShort = playerParametersAreShort(signature);
playerResponseVideoIdIsShort = isShort;
if (!isShort || isShortAndOpeningOrPlaying) {
if (videoIdIsShort != isShort) {
videoIdIsShort = isShort;
Logger.printDebug(() -> "videoIdIsShort: " + isShort);
}
}
return signature; // 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)) {
Logger.printDebug(() -> "New player response video id: " + videoId);
playerResponseVideoId = videoId;
}
}
/**
* Injection point.
*/
public static void videoSpeedChanged(float currentVideoSpeed) {
if (playbackSpeed != currentVideoSpeed) {
Logger.printDebug(() -> "Video speed changed: " + currentVideoSpeed);
playbackSpeed = currentVideoSpeed;
}
}
/**
* Injection point.
* Called when user selects a playback speed.
*
* @param userSelectedPlaybackSpeed The playback speed the user selected
*/
public static void userSelectedPlaybackSpeed(float userSelectedPlaybackSpeed) {
Logger.printDebug(() -> "User selected playback speed: " + userSelectedPlaybackSpeed);
playbackSpeed = userSelectedPlaybackSpeed;
}
/**
* Injection point.
*
* @param length The length of the video in milliseconds.
*/
public static void setVideoLength(final long length) {
if (videoLength != length) {
Logger.printDebug(() -> "Current video length: " + length);
videoLength = length;
}
}
/**
* 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;
}
/**
* Seek on the current video.
* Does not function for playback of Shorts.
* <p>
* 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) {
Utils.verifyOnMainThread();
try {
final long videoTime = getVideoTime();
final long videoLength = getVideoLength();
// Prevent issues such as play/ pause button or autoplay not working.
final long adjustedSeekTime = Math.min(seekTime, videoLength - 250);
if (videoTime <= seekTime && videoTime >= adjustedSeekTime) {
// Both the current video time and the seekTo are in the last 250ms of the video.
// Ignore this seek call, otherwise if a video ends with multiple closely timed segments
// then seeking here can create an infinite loop of skip attempts.
Logger.printDebug(() -> "Ignoring seekTo call as video playback is almost finished. "
+ " videoTime: " + videoTime + " videoLength: " + videoLength + " seekTo: " + seekTime);
return false;
}
Logger.printDebug(() -> "Seeking to: " + adjustedSeekTime);
// Try regular playback controller first, and it will not succeed if casting.
PlaybackController controller = playerControllerRef.get();
if (controller == null) {
Logger.printDebug(() -> "Cannot seekTo because player controller is null");
} else {
if (controller.seekTo(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;
}
controller = mdxPlayerDirectorRef.get();
if (controller == null) {
Logger.printDebug(() -> "Cannot seekTo MXD because player controller is null");
return false;
}
return controller.seekTo(adjustedSeekTime);
} catch (Exception ex) {
Logger.printException(() -> "Failed to seek", ex);
return false;
}
}
/**
* Seeks a relative amount. Should always be used over {@link #seekTo(long)}
* when the desired seek time is an offset of the current time.
*/
public static void seekToRelative(long seekTime) {
Utils.verifyOnMainThread();
try {
Logger.printDebug(() -> "Seeking relative to: " + seekTime);
// 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 {
controller.seekToRelative(seekTime);
}
// 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;
if (seekTime < 0) {
adjustedSeekTime = Math.min(seekTime, -1000);
} else {
adjustedSeekTime = Math.max(seekTime, 1000);
}
controller = mdxPlayerDirectorRef.get();
if (controller == null) {
Logger.printDebug(() -> "Cannot seek relative as MXD player controller is null");
} else {
controller.seekToRelative(adjustedSeekTime);
}
} catch (Exception ex) {
Logger.printException(() -> "Failed to seek relative", 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;
}
/**
* Differs from {@link #videoId} as this is the video id for the
* last player response received, which may not be the last video opened.
* <p>
* If Shorts are loading the background, this commonly will be
* different from the Short that is currently on screen.
* <p>
* 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 The current playback speed.
*/
public static float getPlaybackSpeed() {
return playbackSpeed;
}
/**
* 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.
* <p>
* Value will lag behind the actual playback time by a variable amount based on the playback speed.
* <p>
* 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;
}
/**
* @return If the playback is at the end of the video.
* <p>
* If video is playing in the background with no video visible,
* this always returns false (even if the video is actually at the end).
* <p>
* 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
*/
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
public static boolean isAtEndOfVideo() {
return videoTime >= videoLength && videoLength > 0;
}
}

View File

@ -0,0 +1,11 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class WideSearchbarPatch {
public static boolean enableWideSearchbar(boolean original) {
return Settings.WIDE_SEARCHBAR.get() || original;
}
}

View File

@ -0,0 +1,10 @@
package app.revanced.extension.youtube.patches;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class ZoomHapticsPatch {
public static boolean shouldVibrate() {
return !Settings.DISABLE_ZOOM_HAPTICS.get();
}
}

View File

@ -0,0 +1,172 @@
package app.revanced.extension.youtube.patches.announcements;
import static android.text.Html.FROM_HTML_MODE_COMPACT;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.youtube.patches.announcements.requests.AnnouncementsRoutes.GET_LATEST_ANNOUNCEMENTS;
import static app.revanced.extension.youtube.patches.announcements.requests.AnnouncementsRoutes.GET_LATEST_ANNOUNCEMENT_IDS;
import android.app.Activity;
import android.app.AlertDialog;
import android.os.Build;
import android.text.Html;
import android.text.method.LinkMovementMethod;
import android.widget.TextView;
import androidx.annotation.RequiresApi;
import org.json.JSONArray;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.time.LocalDateTime;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.requests.Requester;
import app.revanced.extension.youtube.patches.announcements.requests.AnnouncementsRoutes;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class AnnouncementsPatch {
private AnnouncementsPatch() {
}
@RequiresApi(api = Build.VERSION_CODES.O)
private static boolean isLatestAlready() throws IOException {
HttpURLConnection connection =
AnnouncementsRoutes.getAnnouncementsConnectionFromRoute(GET_LATEST_ANNOUNCEMENT_IDS);
Logger.printDebug(() -> "Get latest announcement IDs route connection url: " + connection.getURL());
try {
// Do not show the announcement if the request failed.
if (connection.getResponseCode() != 200) {
if (Settings.ANNOUNCEMENT_LAST_ID.isSetToDefault())
return true;
Settings.ANNOUNCEMENT_LAST_ID.resetToDefault();
Utils.showToastLong(str("revanced_announcements_connection_failed"));
return true;
}
} catch (IOException ex) {
Logger.printException(() -> "Could not connect to announcements provider", ex);
return true;
}
var jsonString = Requester.parseStringAndDisconnect(connection);
// Parse the ID. Fall-back to raw string if it fails.
int id = Settings.ANNOUNCEMENT_LAST_ID.defaultValue;
try {
final var announcementIds = new JSONArray(jsonString);
id = announcementIds.getJSONObject(0).getInt("id");
} catch (Throwable ex) {
Logger.printException(() -> "Failed to parse announcement IDs", ex);
}
// Do not show the announcement, if the last announcement id is the same as the current one.
return Settings.ANNOUNCEMENT_LAST_ID.get() == id;
}
@RequiresApi(api = Build.VERSION_CODES.O)
public static void showAnnouncement(final Activity context) {
if (!Settings.ANNOUNCEMENTS.get()) return;
// Check if there is internet connection
if (!Utils.isNetworkConnected()) return;
Utils.runOnBackgroundThread(() -> {
try {
if (isLatestAlready()) return;
HttpURLConnection connection = AnnouncementsRoutes
.getAnnouncementsConnectionFromRoute(GET_LATEST_ANNOUNCEMENTS);
Logger.printDebug(() -> "Get latest announcements route connection url: " + connection.getURL());
var jsonString = Requester.parseStringAndDisconnect(connection);
// Parse the announcement. Fall-back to raw string if it fails.
int id = Settings.ANNOUNCEMENT_LAST_ID.defaultValue;
String title;
String message;
LocalDateTime archivedAt = LocalDateTime.MAX;
Level level = Level.INFO;
try {
final var announcement = new JSONArray(jsonString).getJSONObject(0);
id = announcement.getInt("id");
title = announcement.getString("title");
message = announcement.getString("content");
if (!announcement.isNull("archived_at")) {
archivedAt = LocalDateTime.parse(announcement.getString("archived_at"));
}
if (!announcement.isNull("level")) {
level = Level.fromInt(announcement.getInt("level"));
}
} catch (Throwable ex) {
Logger.printException(() -> "Failed to parse announcement. Fall-backing to raw string", ex);
title = "Announcement";
message = jsonString;
}
// If the announcement is archived, do not show it.
if (archivedAt.isBefore(LocalDateTime.now())) {
Settings.ANNOUNCEMENT_LAST_ID.save(id);
return;
}
int finalId = id;
final var finalTitle = title;
final var finalMessage = Html.fromHtml(message, FROM_HTML_MODE_COMPACT);
final Level finalLevel = level;
Utils.runOnMainThread(() -> {
// Show the announcement.
var alert = new AlertDialog.Builder(context)
.setTitle(finalTitle)
.setMessage(finalMessage)
.setIcon(finalLevel.icon)
.setPositiveButton(android.R.string.ok, (dialog, which) -> {
Settings.ANNOUNCEMENT_LAST_ID.save(finalId);
dialog.dismiss();
}).setNegativeButton(str("revanced_announcements_dialog_dismiss"), (dialog, which) -> {
dialog.dismiss();
})
.setCancelable(false)
.create();
Utils.showDialog(context, alert, false, (AlertDialog dialog) -> {
// Make links clickable.
((TextView) dialog.findViewById(android.R.id.message))
.setMovementMethod(LinkMovementMethod.getInstance());
});
});
} catch (Exception e) {
final var message = "Failed to get announcement";
Logger.printException(() -> message, e);
}
});
}
// TODO: Use better icons.
private enum Level {
INFO(android.R.drawable.ic_dialog_info),
WARNING(android.R.drawable.ic_dialog_alert),
SEVERE(android.R.drawable.ic_dialog_alert);
public final int icon;
Level(int icon) {
this.icon = icon;
}
public static Level fromInt(int value) {
return values()[Math.min(value, values().length - 1)];
}
}
}

View File

@ -0,0 +1,22 @@
package app.revanced.extension.youtube.patches.announcements.requests;
import app.revanced.extension.shared.requests.Requester;
import app.revanced.extension.shared.requests.Route;
import java.io.IOException;
import java.net.HttpURLConnection;
import static app.revanced.extension.shared.requests.Route.Method.GET;
public class AnnouncementsRoutes {
private static final String ANNOUNCEMENTS_PROVIDER = "https://api.revanced.app/v4";
public static final Route GET_LATEST_ANNOUNCEMENT_IDS = new Route(GET, "/announcements/latest/id?tag=youtube");
public static final Route GET_LATEST_ANNOUNCEMENTS = new Route(GET, "/announcements/latest?tag=youtube");
private AnnouncementsRoutes() {
}
public static HttpURLConnection getAnnouncementsConnectionFromRoute(Route route, String... params) throws IOException {
return Requester.getConnectionFromRoute(ANNOUNCEMENTS_PROVIDER, route, params);
}
}

View File

@ -0,0 +1,242 @@
package app.revanced.extension.youtube.patches.components;
import static app.revanced.extension.shared.StringRef.str;
import android.app.Instrumentation;
import android.view.KeyEvent;
import android.view.View;
import androidx.annotation.Nullable;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.StringTrieSearch;
@SuppressWarnings("unused")
public final class AdsFilter extends Filter {
// region Fullscreen ad
private static volatile long lastTimeClosedFullscreenAd;
private static final Instrumentation instrumentation = new Instrumentation();
private final StringFilterGroup fullscreenAd;
// endregion
private final StringTrieSearch exceptions = new StringTrieSearch();
private final StringFilterGroup playerShoppingShelf;
private final ByteArrayFilterGroup playerShoppingShelfBuffer;
private final StringFilterGroup channelProfile;
private final ByteArrayFilterGroup visitStoreButton;
private final StringFilterGroup shoppingLinks;
public AdsFilter() {
exceptions.addPatterns(
"home_video_with_context", // Don't filter anything in the home page video component.
"related_video_with_context", // Don't filter anything in the related video component.
"comment_thread", // Don't filter anything in the comments.
"|comment.", // Don't filter anything in the comments replies.
"library_recent_shelf"
);
// Identifiers.
final var carouselAd = new StringFilterGroup(
Settings.HIDE_GENERAL_ADS,
"carousel_ad"
);
addIdentifierCallbacks(carouselAd);
// Paths.
fullscreenAd = new StringFilterGroup(
Settings.HIDE_FULLSCREEN_ADS,
"_interstitial"
);
final var buttonedAd = new StringFilterGroup(
Settings.HIDE_BUTTONED_ADS,
"_ad_with",
"_buttoned_layout",
// text_image_button_group_layout, landscape_image_button_group_layout, full_width_square_image_button_group_layout
"image_button_group_layout",
"full_width_square_image_layout",
"video_display_button_group_layout",
"landscape_image_wide_button_layout",
"video_display_carousel_button_group_layout"
);
final var generalAds = new StringFilterGroup(
Settings.HIDE_GENERAL_ADS,
"ads_video_with_context",
"banner_text_icon",
"square_image_layout",
"watch_metadata_app_promo",
"video_display_full_layout",
"hero_promo_image",
"statement_banner",
"carousel_footered_layout",
"text_image_button_layout",
"primetime_promo",
"product_details",
"composite_concurrent_carousel_layout",
"carousel_headered_layout",
"full_width_portrait_image_layout",
"brand_video_shelf"
);
final var movieAds = new StringFilterGroup(
Settings.HIDE_MOVIES_SECTION,
"browsy_bar",
"compact_movie",
"horizontal_movie_shelf",
"movie_and_show_upsell_card",
"compact_tvfilm_item",
"offer_module_root"
);
final var viewProducts = new StringFilterGroup(
Settings.HIDE_PRODUCTS_BANNER,
"product_item",
"products_in_video",
"shopping_overlay.eml", // Video player overlay shopping links.
"shopping_carousel.eml" // Channel profile shopping shelf.
);
shoppingLinks = new StringFilterGroup(
Settings.HIDE_SHOPPING_LINKS,
"expandable_list"
);
channelProfile = new StringFilterGroup(
null,
"channel_profile.eml"
);
playerShoppingShelf = new StringFilterGroup(
null,
"horizontal_shelf.eml"
);
playerShoppingShelfBuffer = new ByteArrayFilterGroup(
Settings.HIDE_PLAYER_STORE_SHELF,
"shopping_item_card_list.eml"
);
visitStoreButton = new ByteArrayFilterGroup(
Settings.HIDE_VISIT_STORE_BUTTON,
"header_store_button"
);
final var webLinkPanel = new StringFilterGroup(
Settings.HIDE_WEB_SEARCH_RESULTS,
"web_link_panel"
);
final var merchandise = new StringFilterGroup(
Settings.HIDE_MERCHANDISE_BANNERS,
"product_carousel"
);
final var selfSponsor = new StringFilterGroup(
Settings.HIDE_SELF_SPONSOR,
"cta_shelf_card"
);
addPathCallbacks(
generalAds,
buttonedAd,
merchandise,
viewProducts,
selfSponsor,
fullscreenAd,
channelProfile,
webLinkPanel,
shoppingLinks,
playerShoppingShelf,
movieAds
);
}
@Override
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
if (matchedGroup == playerShoppingShelf) {
if (contentIndex == 0 && playerShoppingShelfBuffer.check(protobufBufferArray).isFiltered()) {
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
return false;
}
if (exceptions.matches(path))
return false;
if (matchedGroup == fullscreenAd) {
if (path.contains("|ImageType|")) closeFullscreenAd();
return false; // Do not actually filter the fullscreen ad otherwise it will leave a dimmed screen.
}
if (matchedGroup == channelProfile) {
if (visitStoreButton.check(protobufBufferArray).isFiltered()) {
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
return false;
}
// Check for the index because of likelihood of false positives.
if (matchedGroup == shoppingLinks && contentIndex != 0)
return false;
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
/**
* Hide the view, which shows ads in the homepage.
*
* @param view The view, which shows ads.
*/
public static void hideAdAttributionView(View view) {
Utils.hideViewBy0dpUnderCondition(Settings.HIDE_GENERAL_ADS, view);
}
/**
* Close the fullscreen ad.
* <p>
* The strategy is to send a back button event to the app to close the fullscreen ad using the back button event.
*/
private static void closeFullscreenAd() {
final var currentTime = System.currentTimeMillis();
// Prevent spamming the back button.
if (currentTime - lastTimeClosedFullscreenAd < 10000) return;
lastTimeClosedFullscreenAd = currentTime;
Logger.printDebug(() -> "Closing fullscreen ad");
Utils.runOnMainThreadDelayed(() -> {
// Must run off main thread (Odd, but whatever).
Utils.runOnBackgroundThread(() -> {
try {
instrumentation.sendKeyDownUpSync(KeyEvent.KEYCODE_BACK);
} catch (Exception ex) {
// Injecting user events on Android 10+ requires the manifest to include
// INJECT_EVENTS, and it's usage is heavily restricted
// and requires the user to manually approve the permission in the device settings.
//
// And no matter what, permissions cannot be added for root installations
// as manifest changes are ignored for mount installations.
//
// Instead, catch the SecurityException and turn off hide full screen ads
// since this functionality does not work for these devices.
Logger.printInfo(() -> "Could not inject back button event", ex);
Settings.HIDE_FULLSCREEN_ADS.save(false);
Utils.showToastLong(str("revanced_hide_fullscreen_ads_feature_not_available_toast"));
}
});
}, 1000);
}
}

View File

@ -0,0 +1,103 @@
package app.revanced.extension.youtube.patches.components;
import androidx.annotation.Nullable;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
final class ButtonsFilter extends Filter {
private static final String VIDEO_ACTION_BAR_PATH = "video_action_bar.eml";
private final StringFilterGroup actionBarGroup;
private final StringFilterGroup bufferFilterPathGroup;
private final ByteArrayFilterGroupList bufferButtonsGroupList = new ByteArrayFilterGroupList();
public ButtonsFilter() {
actionBarGroup = new StringFilterGroup(
null,
VIDEO_ACTION_BAR_PATH
);
addIdentifierCallbacks(actionBarGroup);
bufferFilterPathGroup = new StringFilterGroup(
null,
"|ContainerType|button.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_PLAYLIST_BUTTON,
"|save_to_playlist_button"
),
new StringFilterGroup(
Settings.HIDE_CLIP_BUTTON,
"|clip_button.eml|"
),
bufferFilterPathGroup
);
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"
),
// Check for clip button both here and using a path filter,
// as there's a chance the path is a generic action button and won't contain 'clip_button'
new ByteArrayFilterGroup(
Settings.HIDE_CLIP_BUTTON,
"yt_outline_scissors"
),
new ByteArrayFilterGroup(
Settings.HIDE_THANKS_BUTTON,
"yt_outline_dollar_sign_heart"
)
);
}
private boolean isEveryFilterGroupEnabled() {
for (var group : pathCallbacks)
if (!group.isEnabled()) return false;
for (var group : bufferButtonsGroupList)
if (!group.isEnabled()) return false;
return true;
}
@Override
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
// If the current matched group is the action bar group,
// in case every filter group is enabled, hide the action bar.
if (matchedGroup == actionBarGroup) {
if (!isEveryFilterGroupEnabled()) {
return false;
}
} else if (matchedGroup == bufferFilterPathGroup) {
// Make sure the current path is the right one
// to avoid false positives.
if (!path.startsWith(VIDEO_ACTION_BAR_PATH)) return false;
// In case the group list has no match, return false.
if (!bufferButtonsGroupList.check(protobufBufferArray).isFiltered()) return false;
}
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
}

View File

@ -0,0 +1,83 @@
package app.revanced.extension.youtube.patches.components;
import androidx.annotation.Nullable;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
final class CommentsFilter extends Filter {
private static final String TIMESTAMP_OR_EMOJI_BUTTONS_ENDS_WITH_PATH
= "|CellType|ContainerType|ContainerType|ContainerType|ContainerType|ContainerType|";
private final StringFilterGroup commentComposer;
private final ByteArrayFilterGroup emojiPickerBufferGroup;
public CommentsFilter() {
var commentsByMembers = new StringFilterGroup(
Settings.HIDE_COMMENTS_BY_MEMBERS_HEADER,
"sponsorships_comments_header.eml",
"sponsorships_comments_footer.eml"
);
var comments = new StringFilterGroup(
Settings.HIDE_COMMENTS_SECTION,
"video_metadata_carousel",
"_comments"
);
var createAShort = new StringFilterGroup(
Settings.HIDE_COMMENTS_CREATE_A_SHORT_BUTTON,
"composer_short_creation_button.eml"
);
var previewComment = new StringFilterGroup(
Settings.HIDE_COMMENTS_PREVIEW_COMMENT,
"|carousel_item",
"comments_entry_point_teaser",
"comments_entry_point_simplebox"
);
var thanksButton = new StringFilterGroup(
Settings.HIDE_COMMENTS_THANKS_BUTTON,
"super_thanks_button.eml"
);
commentComposer = new StringFilterGroup(
Settings.HIDE_COMMENTS_TIMESTAMP_AND_EMOJI_BUTTONS,
"comment_composer.eml"
);
emojiPickerBufferGroup = new ByteArrayFilterGroup(
null,
"id.comment.quick_emoji.button"
);
addPathCallbacks(
commentsByMembers,
comments,
createAShort,
previewComment,
thanksButton,
commentComposer
);
}
@Override
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
if (matchedGroup == commentComposer) {
// To completely hide the emoji buttons (and leave no empty space), the timestamp button is
// also hidden because the buffer is exactly the same and there's no way selectively hide.
if (contentIndex == 0
&& path.endsWith(TIMESTAMP_OR_EMOJI_BUTTONS_ENDS_WITH_PATH)
&& emojiPickerBufferGroup.check(protobufBufferArray).isFiltered()) {
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
return false;
}
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
}

View File

@ -0,0 +1,161 @@
package app.revanced.extension.youtube.patches.components;
import static app.revanced.extension.shared.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.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.ByteTrieSearch;
import app.revanced.extension.youtube.settings.Settings;
/**
* Allows custom filtering using a path and optionally a proto buffer string.
*/
@SuppressWarnings("unused")
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<CustomFilterGroup> 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<String, CustomFilterGroup> 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<CustomFilterGroup> groups = CustomFilterGroup.parseCustomFilterGroups();
if (!groups.isEmpty()) {
CustomFilterGroup[] groupsArray = groups.toArray(new CustomFilterGroup[0]);
Logger.printDebug(()-> "Using Custom filters: " + Arrays.toString(groupsArray));
addPathCallbacks(groupsArray);
}
}
@Override
boolean isFiltered(@Nullable String identifier, String path, 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(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
}

View File

@ -0,0 +1,88 @@
package app.revanced.extension.youtube.patches.components;
import androidx.annotation.Nullable;
import app.revanced.extension.youtube.StringTrieSearch;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
final class DescriptionComponentsFilter extends Filter {
private final StringTrieSearch exceptions = new StringTrieSearch();
private final ByteArrayFilterGroupList macroMarkersCarouselGroupList = new ByteArrayFilterGroupList();
private final StringFilterGroup macroMarkersCarousel;
public DescriptionComponentsFilter() {
exceptions.addPatterns(
"compact_channel",
"description",
"grid_video",
"inline_expander",
"metadata"
);
final StringFilterGroup attributesSection = new StringFilterGroup(
Settings.HIDE_ATTRIBUTES_SECTION,
"gaming_section",
"music_section",
"video_attributes_section"
);
final StringFilterGroup infoCardsSection = new StringFilterGroup(
Settings.HIDE_INFO_CARDS_SECTION,
"infocards_section"
);
final StringFilterGroup podcastSection = new StringFilterGroup(
Settings.HIDE_PODCAST_SECTION,
"playlist_section"
);
final StringFilterGroup transcriptSection = new StringFilterGroup(
Settings.HIDE_TRANSCRIPT_SECTION,
"transcript_section"
);
macroMarkersCarousel = new StringFilterGroup(
null,
"macro_markers_carousel.eml"
);
macroMarkersCarouselGroupList.addAll(
new ByteArrayFilterGroup(
Settings.HIDE_CHAPTERS_SECTION,
"chapters_horizontal_shelf"
),
new ByteArrayFilterGroup(
Settings.HIDE_KEY_CONCEPTS_SECTION,
"learning_concept_macro_markers_carousel_shelf"
)
);
addPathCallbacks(
attributesSection,
infoCardsSection,
podcastSection,
transcriptSection,
macroMarkersCarousel
);
}
@Override
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
if (exceptions.matches(path)) return false;
if (matchedGroup == macroMarkersCarousel) {
if (contentIndex == 0 && macroMarkersCarouselGroupList.check(protobufBufferArray).isFiltered()) {
return super.isFiltered(path, identifier, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
return false;
}
return super.isFiltered(path, identifier, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
}

View File

@ -0,0 +1,90 @@
package app.revanced.extension.youtube.patches.components;
import androidx.annotation.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.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;
}
}

View File

@ -0,0 +1,214 @@
package app.revanced.extension.youtube.patches.components;
import androidx.annotation.NonNull;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.settings.BooleanSetting;
import app.revanced.extension.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);
}
}

View File

@ -0,0 +1,85 @@
package app.revanced.extension.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.extension.youtube.ByteTrieSearch;
import app.revanced.extension.youtube.StringTrieSearch;
import app.revanced.extension.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();
}
}

View File

@ -0,0 +1,16 @@
package app.revanced.extension.youtube.patches.components;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class HideInfoCardsFilterPatch extends Filter {
public HideInfoCardsFilterPatch() {
addIdentifierCallbacks(
new StringFilterGroup(
Settings.HIDE_INFO_CARDS,
"info_card_teaser_overlay.eml"
)
);
}
}

View File

@ -0,0 +1,597 @@
package app.revanced.extension.youtube.patches.components;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
import static java.lang.Character.UnicodeBlock.*;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.ByteTrieSearch;
import app.revanced.extension.youtube.StringTrieSearch;
import app.revanced.extension.youtube.TrieSearch;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.NavigationBar;
import app.revanced.extension.youtube.shared.PlayerType;
/**
* <pre>
* 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")
@RequiresApi(api = Build.VERSION_CODES.N)
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.
"home_video_with_context.eml",
"search_video_with_context.eml",
"video_with_context.eml", // Subscription tab videos.
"related_video_with_context.eml",
// A/B test for subscribed video, and sometimes when tablet layout is enabled.
"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 backoff is in effect.
*/
private volatile long timeToResumeFiltering;
/**
* 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;
/**
* 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<String, Boolean> 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<String, Boolean> entry : keywords.entrySet()) {
String keyword = entry.getKey();
//noinspection ExtractMethodRecommender
final boolean isWholeWord = entry.getValue();
TrieSearch.TriePatternMatchedCallback<byte[]> 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<String>) 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() {
// Keywords are parsed on first call to isFiltered()
addPathCallbacks(startsWithFilter, containsFilter);
}
private boolean hideKeywordSettingIsActive() {
if (timeToResumeFiltering != 0) {
if (System.currentTimeMillis() < timeToResumeFiltering) {
return false;
}
timeToResumeFiltering = 0;
filteredVideosPercentage = 0;
Logger.printDebug(() -> "Resuming keyword filtering");
}
// Must check player type first, as search bar can be active behind the player.
if (PlayerType.getCurrent().isMaximizedOrFullscreen()) {
// For now, consider the under video results the same as the home feed.
return Settings.HIDE_KEYWORD_CONTENT_HOME.get();
}
// Must check second, as search can be from any tab.
if (NavigationBar.isSearchBarActive()) {
return Settings.HIDE_KEYWORD_CONTENT_SEARCH.get();
}
// Avoid checking navigation button status if all other settings are off.
final boolean hideHome = Settings.HIDE_KEYWORD_CONTENT_HOME.get();
final boolean hideSubscriptions = Settings.HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS.get();
if (!hideHome && !hideSubscriptions) {
return false;
}
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
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
if (contentIndex != 0 && matchedGroup == startsWithFilter) {
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 (!hideKeywordSettingIsActive()) return false;
if (exceptions.matches(path)) {
return false; // Do not update statistics.
}
MutableReference<String> matchRef = new MutableReference<>();
if (bufferSearch.matches(protobufBufferArray, matchRef)) {
updateStats(true, matchRef.value);
return super.isFiltered(identifier, path, 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> {
T value;
}

View File

@ -0,0 +1,472 @@
package app.revanced.extension.youtube.patches.components;
import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.view.View;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.StringTrieSearch;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.NavigationBar;
import app.revanced.extension.youtube.shared.PlayerType;
@SuppressWarnings("unused")
public final class LayoutComponentsFilter extends Filter {
private static final String COMPACT_CHANNEL_BAR_PATH_PREFIX = "compact_channel_bar.eml";
private static final String VIDEO_ACTION_BAR_PATH_PREFIX = "video_action_bar.eml";
private static final String ANIMATED_VECTOR_TYPE_PATH = "AnimatedVectorType";
private static final StringTrieSearch mixPlaylistsExceptions = new StringTrieSearch(
"V.ED", // Playlist browse id.
"java.lang.ref.WeakReference"
);
private static final ByteArrayFilterGroup mixPlaylistsExceptions2 = new ByteArrayFilterGroup(
null,
"cell_description_body"
);
private static final ByteArrayFilterGroup mixPlaylists = new ByteArrayFilterGroup(
null,
"&list="
);
private final StringTrieSearch exceptions = new StringTrieSearch();
private final StringFilterGroup searchResultShelfHeader;
private final StringFilterGroup inFeedSurvey;
private final StringFilterGroup notifyMe;
private final StringFilterGroup expandableMetadata;
private final ByteArrayFilterGroup searchResultRecommendations;
private final StringFilterGroup searchResultVideo;
private final StringFilterGroup compactChannelBarInner;
private final StringFilterGroup compactChannelBarInnerButton;
private final ByteArrayFilterGroup joinMembershipButton;
private final StringFilterGroup likeSubscribeGlow;
private final StringFilterGroup horizontalShelves;
@RequiresApi(api = Build.VERSION_CODES.N)
public LayoutComponentsFilter() {
exceptions.addPatterns(
"home_video_with_context",
"related_video_with_context",
"search_video_with_context",
"comment_thread", // Whitelist comments
"|comment.", // Whitelist comment replies
"library_recent_shelf"
);
// Identifiers.
final var chipsShelf = new StringFilterGroup(
Settings.HIDE_CHIPS_SHELF,
"chips_shelf"
);
addIdentifierCallbacks(
chipsShelf
);
// Paths.
final var communityPosts = new StringFilterGroup(
Settings.HIDE_COMMUNITY_POSTS,
"post_base_wrapper",
"text_post_root.eml",
"images_post_root.eml",
"images_post_slim.eml",
"images_post_root_slim.eml",
"text_post_root_slim.eml",
"post_base_wrapper_slim.eml"
);
final var communityGuidelines = new StringFilterGroup(
Settings.HIDE_COMMUNITY_GUIDELINES,
"community_guidelines"
);
final var subscribersCommunityGuidelines = new StringFilterGroup(
Settings.HIDE_SUBSCRIBERS_COMMUNITY_GUIDELINES,
"sponsorships_comments_upsell"
);
final var channelMemberShelf = new StringFilterGroup(
Settings.HIDE_CHANNEL_MEMBER_SHELF,
"member_recognition_shelf"
);
final var compactBanner = new StringFilterGroup(
Settings.HIDE_COMPACT_BANNER,
"compact_banner"
);
inFeedSurvey = new StringFilterGroup(
Settings.HIDE_FEED_SURVEY,
"in_feed_survey",
"slimline_survey"
);
final var medicalPanel = new StringFilterGroup(
Settings.HIDE_MEDICAL_PANELS,
"medical_panel"
);
final var paidPromotion = new StringFilterGroup(
Settings.HIDE_PAID_PROMOTION_LABEL,
"paid_content_overlay"
);
final var infoPanel = new StringFilterGroup(
Settings.HIDE_HIDE_INFO_PANELS,
"publisher_transparency_panel",
"single_item_information_panel"
);
final var latestPosts = new StringFilterGroup(
Settings.HIDE_HIDE_LATEST_POSTS,
"post_shelf"
);
final var channelGuidelines = new StringFilterGroup(
Settings.HIDE_HIDE_CHANNEL_GUIDELINES,
"channel_guidelines_entry_banner"
);
final var emergencyBox = new StringFilterGroup(
Settings.HIDE_EMERGENCY_BOX,
"emergency_onebox"
);
// The player audio track button does the exact same function as the audio track flyout menu option.
// Previously this was a setting to show/hide the player button.
// But it was decided it's simpler to always hide this button because:
// - 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 var audioTrackButton = new StringFilterGroup(
null,
"multi_feed_icon_button"
);
final var artistCard = new StringFilterGroup(
Settings.HIDE_ARTIST_CARDS,
"official_card"
);
expandableMetadata = new StringFilterGroup(
Settings.HIDE_EXPANDABLE_CHIP,
"inline_expander"
);
final var channelBar = new StringFilterGroup(
Settings.HIDE_CHANNEL_BAR,
"channel_bar"
);
final var relatedVideos = new StringFilterGroup(
Settings.HIDE_RELATED_VIDEOS,
"fullscreen_related_videos"
);
final var playables = new StringFilterGroup(
Settings.HIDE_PLAYABLES,
"horizontal_gaming_shelf.eml",
"mini_game_card.eml"
);
final var quickActions = new StringFilterGroup(
Settings.HIDE_QUICK_ACTIONS,
"quick_actions"
);
final var imageShelf = new StringFilterGroup(
Settings.HIDE_IMAGE_SHELF,
"image_shelf"
);
final var timedReactions = new StringFilterGroup(
Settings.HIDE_TIMED_REACTIONS,
"emoji_control_panel",
"timed_reaction"
);
searchResultShelfHeader = new StringFilterGroup(
Settings.HIDE_SEARCH_RESULT_SHELF_HEADER,
"shelf_header.eml"
);
notifyMe = new StringFilterGroup(
Settings.HIDE_NOTIFY_ME_BUTTON,
"set_reminder_button"
);
compactChannelBarInner = new StringFilterGroup(
Settings.HIDE_JOIN_MEMBERSHIP_BUTTON,
"compact_channel_bar_inner"
);
compactChannelBarInnerButton = new StringFilterGroup(
null,
"|button.eml|"
);
joinMembershipButton = new ByteArrayFilterGroup(
null,
"sponsorships"
);
likeSubscribeGlow = new StringFilterGroup(
Settings.DISABLE_LIKE_SUBSCRIBE_GLOW,
"animated_button_border.eml"
);
final var channelWatermark = new StringFilterGroup(
Settings.HIDE_VIDEO_CHANNEL_WATERMARK,
"featured_channel_watermark_overlay"
);
final var forYouShelf = new StringFilterGroup(
Settings.HIDE_FOR_YOU_SHELF,
"mixed_content_shelf"
);
searchResultVideo = new StringFilterGroup(
Settings.HIDE_SEARCH_RESULT_RECOMMENDATIONS,
"search_video_with_context.eml"
);
searchResultRecommendations = new ByteArrayFilterGroup(
Settings.HIDE_SEARCH_RESULT_RECOMMENDATIONS,
"endorsement_header_footer"
);
horizontalShelves = new StringFilterGroup(
Settings.HIDE_HORIZONTAL_SHELVES,
"horizontal_video_shelf.eml",
"horizontal_shelf.eml",
"horizontal_shelf_inline.eml",
"horizontal_tile_shelf.eml"
);
addPathCallbacks(
expandableMetadata,
inFeedSurvey,
notifyMe,
likeSubscribeGlow,
channelBar,
communityPosts,
paidPromotion,
searchResultVideo,
latestPosts,
channelWatermark,
communityGuidelines,
playables,
quickActions,
relatedVideos,
compactBanner,
compactChannelBarInner,
medicalPanel,
infoPanel,
emergencyBox,
subscribersCommunityGuidelines,
channelGuidelines,
audioTrackButton,
artistCard,
timedReactions,
imageShelf,
channelMemberShelf,
forYouShelf,
horizontalShelves
);
}
@Override
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
if (matchedGroup == searchResultVideo) {
if (searchResultRecommendations.check(protobufBufferArray).isFiltered()) {
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
return false;
}
if (matchedGroup == likeSubscribeGlow) {
if ((path.startsWith(VIDEO_ACTION_BAR_PATH_PREFIX) || path.startsWith(COMPACT_CHANNEL_BAR_PATH_PREFIX))
&& path.contains(ANIMATED_VECTOR_TYPE_PATH)) {
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
return false;
}
// The groups are excluded from the filter due to the exceptions list below.
// Filter them separately here.
if (matchedGroup == notifyMe || matchedGroup == inFeedSurvey || matchedGroup == expandableMetadata)
{
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
if (exceptions.matches(path)) return false; // Exceptions are not filtered.
if (matchedGroup == compactChannelBarInner) {
if (compactChannelBarInnerButton.check(path).isFiltered()) {
// The filter may be broad, but in the context of a compactChannelBarInnerButton,
// it's safe to assume that the button is the only thing that should be hidden.
if (joinMembershipButton.check(protobufBufferArray).isFiltered()) {
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
}
return false;
}
// TODO: This also hides the feed Shorts shelf header
if (matchedGroup == searchResultShelfHeader && contentIndex != 0) return false;
if (matchedGroup == horizontalShelves) {
if (contentIndex == 0 && hideShelves()) {
return super.isFiltered(path, identifier, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
return false;
}
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
/**
* Injection point.
* Called from a different place then the other filters.
*/
public static boolean filterMixPlaylists(final Object conversionContext, @Nullable final byte[] bytes) {
try {
if (!Settings.HIDE_MIX_PLAYLISTS.get()) {
return false;
}
if (bytes == null) {
Logger.printDebug(() -> "bytes is null");
return false;
}
// Prevent playlist items being hidden, if a mix playlist is present in it.
if (mixPlaylistsExceptions.matches(conversionContext.toString())) {
return false;
}
// Prevent hiding the description of some videos accidentally.
if (mixPlaylistsExceptions2.check(bytes).isFiltered()) {
return false;
}
if (mixPlaylists.check(bytes).isFiltered()) {
Logger.printDebug(() -> "Filtered mix playlist");
return true;
}
} catch (Exception ex) {
Logger.printException(() -> "filterMixPlaylists failure", ex);
}
return false;
}
/**
* Injection point.
*/
public static boolean showWatermark() {
return !Settings.HIDE_VIDEO_CHANNEL_WATERMARK.get();
}
/**
* Injection point.
*/
public static void hideAlbumCard(View view) {
Utils.hideViewBy0dpUnderCondition(Settings.HIDE_ALBUM_CARDS, view);
}
/**
* Injection point.
*/
public static void hideCrowdfundingBox(View view) {
Utils.hideViewBy0dpUnderCondition(Settings.HIDE_CROWDFUNDING_BOX, view);
}
/**
* Injection point.
*/
public static boolean hideFloatingMicrophoneButton(final boolean original) {
return original || Settings.HIDE_FLOATING_MICROPHONE_BUTTON.get();
}
/**
* Injection point.
*/
public static int hideInFeed(final int height) {
return Settings.HIDE_FILTER_BAR_FEED_IN_FEED.get()
? 0
: height;
}
/**
* Injection point.
*/
public static int hideInSearch(int height) {
return Settings.HIDE_FILTER_BAR_FEED_IN_SEARCH.get()
? 0
: height;
}
/**
* Injection point.
*/
public static void hideInRelatedVideos(View chipView) {
Utils.hideViewBy0dpUnderCondition(Settings.HIDE_FILTER_BAR_FEED_IN_RELATED_VIDEOS, chipView);
}
private static final boolean HIDE_DOODLES_ENABLED = Settings.HIDE_DOODLES.get();
/**
* Injection point.
*/
@Nullable
public static Drawable hideYoodles(Drawable animatedYoodle) {
if (HIDE_DOODLES_ENABLED) {
return null;
}
return animatedYoodle;
}
private static final boolean HIDE_SHOW_MORE_BUTTON_ENABLED = Settings.HIDE_SHOW_MORE_BUTTON.get();
/**
* Injection point.
*/
public static void hideShowMoreButton(View view) {
if (HIDE_SHOW_MORE_BUTTON_ENABLED
&& NavigationBar.isSearchBarActive()
// Search bar can be active but behind the player.
&& !PlayerType.getCurrent().isMaximizedOrFullscreen()) {
Utils.hideViewByLayoutParams(view);
}
}
private static boolean hideShelves() {
// If the player is opened while library is selected,
// then filter any recommendations below the player.
if (PlayerType.getCurrent().isMaximizedOrFullscreen()
// Or if the search is active while library is selected, then also filter.
|| NavigationBar.isSearchBarActive()) {
return true;
}
// 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.
return NavigationButton.getSelectedNavigationButton() != NavigationButton.LIBRARY;
}
}

View File

@ -0,0 +1,188 @@
package app.revanced.extension.youtube.patches.components;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import java.util.List;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.youtube.StringTrieSearch;
import app.revanced.extension.youtube.settings.Settings;
@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 byte[] protoBuffer;
LithoFilterParameters(@Nullable String lithoIdentifier, String lithoPath, byte[] protoBuffer) {
this.identifier = lithoIdentifier;
this.path = lithoPath;
this.protoBuffer = protoBuffer;
}
@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( "ID: ");
builder.append(identifier);
builder.append(" Path: ");
builder.append(path);
if (Settings.DEBUG_PROTOBUFFER.get()) {
builder.append(" BufferStrings: ");
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 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<ByteBuffer> bufferThreadLocal = new ThreadLocal<>();
static {
for (Filter filter : filters) {
filterUsingCallbacks(identifierSearchTree, filter,
filter.identifierCallbacks, Filter.FilterContentType.IDENTIFIER);
filterUsingCallbacks(pathSearchTree, filter,
filter.pathCallbacks, Filter.FilterContentType.PATH);
}
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<StringFilterGroup> 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.identifier, parameters.path, parameters.protoBuffer,
group, type, matchedStartIndex);
}
);
}
}
}
/**
* Injection point. Called off the main thread.
*/
@SuppressWarnings("unused")
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.
if (protobufBuffer == null) {
// It appears the buffer can be cleared out just before the call to #filter()
// Ignore this null value and retain the last buffer that was set.
Logger.printDebug(() -> "Ignoring null protobuffer");
} else {
bufferThreadLocal.set(protobufBuffer);
}
}
/**
* Injection point. Called off the main thread, and commonly called by multiple threads at the same time.
*/
@SuppressWarnings("unused")
public static boolean filter(@Nullable String lithoIdentifier, @NonNull StringBuilder pathBuilder) {
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/path filters still work correctly.
if (protobufBuffer == null) {
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(lithoIdentifier,
pathBuilder.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;
}
} catch (Exception ex) {
Logger.printException(() -> "Litho filter failure", ex);
}
return false;
}
}
/**
* Placeholder for actual filters.
*/
final class DummyFilter extends Filter { }

View File

@ -0,0 +1,51 @@
package app.revanced.extension.youtube.patches.components;
import androidx.annotation.Nullable;
import app.revanced.extension.youtube.patches.playback.speed.CustomPlaybackSpeedPatch;
import app.revanced.extension.youtube.settings.Settings;
/**
* Abuse LithoFilter for {@link CustomPlaybackSpeedPatch}.
*/
public final class PlaybackSpeedMenuFilterPatch 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 PlaybackSpeedMenuFilterPatch() {
// 0.05x litho speed menu.
var playbackRateSelectorGroup = new StringFilterGroup(
Settings.CUSTOM_SPEED_MENU,
"playback_rate_selector_menu_sheet.eml-js"
);
// Old litho based speed menu.
oldPlaybackMenuGroup = new StringFilterGroup(
Settings.CUSTOM_SPEED_MENU,
"playback_speed_sheet_content.eml-js");
addPathCallbacks(playbackRateSelectorGroup, oldPlaybackMenuGroup);
}
@Override
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
if (matchedGroup == oldPlaybackMenuGroup) {
isOldPlaybackSpeedMenuVisible = true;
} else {
isPlaybackRateSelectorMenuVisible = true;
}
return false;
}
}

View File

@ -0,0 +1,112 @@
package app.revanced.extension.youtube.patches.components;
import android.os.Build;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.PlayerType;
@SuppressWarnings("unused")
public class PlayerFlyoutMenuItemsFilter extends Filter {
private final ByteArrayFilterGroupList flyoutFilterGroupList = new ByteArrayFilterGroupList();
private final ByteArrayFilterGroup exception;
private final StringFilterGroup videoQualityMenuFooter;
@RequiresApi(api = Build.VERSION_CODES.N)
public PlayerFlyoutMenuItemsFilter() {
exception = new ByteArrayFilterGroup(
// Whitelist Quality menu item when "Hide Additional settings menu" is enabled
Settings.HIDE_PLAYER_FLYOUT_ADDITIONAL_SETTINGS,
"quality_sheet"
);
videoQualityMenuFooter = new StringFilterGroup(
Settings.HIDE_PLAYER_FLYOUT_VIDEO_QUALITY_FOOTER,
"quality_sheet_footer"
);
addPathCallbacks(
videoQualityMenuFooter,
new StringFilterGroup(null, "overflow_menu_item.eml|")
);
flyoutFilterGroupList.addAll(
new ByteArrayFilterGroup(
Settings.HIDE_PLAYER_FLYOUT_CAPTIONS,
"closed_caption"
),
new ByteArrayFilterGroup(
Settings.HIDE_PLAYER_FLYOUT_ADDITIONAL_SETTINGS,
"yt_outline_gear"
),
new ByteArrayFilterGroup(
Settings.HIDE_PLAYER_FLYOUT_LOOP_VIDEO,
"yt_outline_arrow_repeat_1_"
),
new ByteArrayFilterGroup(
Settings.HIDE_PLAYER_FLYOUT_AMBIENT_MODE,
"yt_outline_screen_light"
),
new ByteArrayFilterGroup(
Settings.HIDE_PLAYER_FLYOUT_STABLE_VOLUME,
"volume_stable"
),
new ByteArrayFilterGroup(
Settings.HIDE_PLAYER_FLYOUT_HELP,
"yt_outline_question_circle"
),
new ByteArrayFilterGroup(
Settings.HIDE_PLAYER_FLYOUT_MORE_INFO,
"yt_outline_info_circle"
),
new ByteArrayFilterGroup(
Settings.HIDE_PLAYER_FLYOUT_LOCK_SCREEN,
"yt_outline_lock"
),
new ByteArrayFilterGroup(
Settings.HIDE_PLAYER_FLYOUT_SPEED,
"yt_outline_play_arrow_half_circle"
),
new ByteArrayFilterGroup(
Settings.HIDE_PLAYER_FLYOUT_AUDIO_TRACK,
"yt_outline_person_radar"
),
new ByteArrayFilterGroup(
Settings.HIDE_PLAYER_FLYOUT_SLEEP_TIMER,
"yt_outline_moon_z_"
),
new ByteArrayFilterGroup(
Settings.HIDE_PLAYER_FLYOUT_WATCH_IN_VR,
"yt_outline_vr"
)
);
}
@Override
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
if (matchedGroup == videoQualityMenuFooter) {
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
if (contentIndex != 0) {
return false; // Overflow menu is always the start of the path.
}
// Shorts also use this player flyout panel
if (PlayerType.getCurrent().isNoneOrHidden() || exception.check(protobufBufferArray).isFiltered()) {
return false;
}
if (flyoutFilterGroupList.check(protobufBufferArray).isFiltered()) {
// Super class handles logging.
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
return false;
}
}

View File

@ -0,0 +1,139 @@
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.youtube.patches.ReturnYouTubeDislikePatch;
import app.revanced.extension.youtube.patches.VideoInformation;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.youtube.TrieSearch;
/**
* 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<String, Boolean> 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(Entry eldest) {
return size() > NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK;
}
};
/**
* Injection point.
*/
@SuppressWarnings("unused")
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);
}
}
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 button identifiers is binary data and then the video id for that specific short.
videoIdFilterGroup.addAll(
new ByteArrayFilterGroup(null, "id.reel_like_button"),
new ByteArrayFilterGroup(null, "id.reel_dislike_button")
);
}
@Override
boolean isFiltered(@Nullable String identifier, String path, 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;
}
}
/**
* 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;
}
}

View File

@ -0,0 +1,443 @@
package app.revanced.extension.youtube.patches.components;
import static app.revanced.extension.shared.Utils.hideViewUnderCondition;
import static app.revanced.extension.youtube.shared.NavigationBar.NavigationButton;
import android.view.View;
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.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.NavigationBar;
import app.revanced.extension.youtube.shared.PlayerType;
@SuppressWarnings("unused")
public final class ShortsFilter extends Filter {
private static final boolean HIDE_SHORTS_NAVIGATION_BAR = Settings.HIDE_SHORTS_NAVIGATION_BAR.get();
private static final String REEL_CHANNEL_BAR_PATH = "reel_channel_bar.eml";
/**
* For paid promotion label and subscribe button that appears in the channel bar.
*/
private static final 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;
private final StringFilterGroup subscribeButton;
private final StringFilterGroup joinButton;
private final StringFilterGroup paidPromotionButton;
private final StringFilterGroup shelfHeader;
private final StringFilterGroup suggestedAction;
private final ByteArrayFilterGroupList suggestedActionsGroupList = new ByteArrayFilterGroupList();
private final StringFilterGroup actionButton;
private final ByteArrayFilterGroupList videoActionButtonGroupList = new ByteArrayFilterGroupList();
public ShortsFilter() {
//
// Identifier components.
//
var shortsIdentifiers = new StringFilterGroup(
null, // Setting is based on navigation state.
"shorts_shelf",
"inline_shorts",
"shorts_grid",
"shorts_video_cell",
"shorts_pivot_item"
);
// Feed Shorts shelf header.
// Use a different filter group for this pattern, as it requires an additional check after matching.
shelfHeader = new StringFilterGroup(
null,
"shelf_header.eml"
);
addIdentifierCallbacks(shortsIdentifiers, shelfHeader);
//
// Path components.
//
shortsCompactFeedVideoPath = new StringFilterGroup(null,
// Shorts that appear in the feed/search when the device is using tablet layout.
"compact_video.eml",
// 'video_lockup_with_attachment.eml' is shown instead of 'compact_video.eml' for some users
"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.
shortsCompactFeedVideoBuffer = new ByteArrayFilterGroup(null, "/frame0.jpg");
// Shorts player components.
StringFilterGroup pausedOverlayButtons = new StringFilterGroup(
Settings.HIDE_SHORTS_PAUSED_OVERLAY_BUTTONS,
"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 soundButton = new StringFilterGroup(
Settings.HIDE_SHORTS_SOUND_BUTTON,
"reel_pivot_button"
);
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 likeFountain = new StringFilterGroup(
Settings.HIDE_SHORTS_LIKE_FOUNTAIN,
"like_fountain.eml"
);
joinButton = new StringFilterGroup(
Settings.HIDE_SHORTS_JOIN_BUTTON,
"sponsor_button"
);
subscribeButton = new StringFilterGroup(
Settings.HIDE_SHORTS_SUBSCRIBE_BUTTON,
"subscribe_button"
);
paidPromotionButton = new StringFilterGroup(
Settings.HIDE_PAID_PROMOTION_LABEL,
"reel_player_disclosure.eml"
);
actionButton = new StringFilterGroup(
null,
"shorts_video_action_button.eml"
);
suggestedAction = new StringFilterGroup(
null,
"suggested_action.eml"
);
addPathCallbacks(
shortsCompactFeedVideoPath, suggestedAction, actionButton, joinButton, subscribeButton,
paidPromotionButton, pausedOverlayButtons, channelBar, fullVideoLinkLabel, videoTitle,
reelSoundMetadata, soundButton, infoPanel, stickers, likeFountain
);
//
// 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"
)
);
//
// Suggested actions.
//
suggestedActionsGroupList.addAll(
new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_SHOP_BUTTON,
"yt_outline_bag_"
),
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_LOCATION_LABEL,
"yt_outline_location_point_"
),
new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_SAVE_SOUND_BUTTON,
"yt_outline_bookmark_",
// 'Save sound' button. It seems this has been removed and only 'Save music' is used.
// Still hide this in case it's still present.
"yt_outline_list_add_",
// 'Use this sound' button. It seems this has been removed and only 'Save music' is used.
// Still hide this in case it's still present.
"yt_outline_camera_"
),
new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_SEARCH_SUGGESTIONS,
"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_UPCOMING_BUTTON,
"yt_outline_bell_"
),
new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_GREEN_SCREEN_BUTTON,
"greenscreen_temp"
),
new ByteArrayFilterGroup(
Settings.HIDE_SHORTS_HASHTAG_BUTTON,
"yt_outline_hashtag_"
)
);
}
private boolean isEverySuggestedActionFilterEnabled() {
for (ByteArrayFilterGroup group : suggestedActionsGroupList) {
if (!group.isEnabled()) {
return false;
}
}
return true;
}
@Override
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
if (contentType == FilterContentType.PATH) {
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 false;
}
if (matchedGroup == shortsCompactFeedVideoPath) {
if (shouldHideShortsFeedItems() && shortsCompactFeedVideoBuffer.check(protobufBufferArray).isFiltered()) {
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
return false;
}
// Video action buttons (like, dislike, comment, share, remix) have the same path.
if (matchedGroup == actionButton) {
if (videoActionButtonGroupList.check(protobufBufferArray).isFiltered()) {
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
return false;
}
if (matchedGroup == suggestedAction) {
// Skip searching the buffer if all suggested actions are set to hidden.
// This has a secondary effect of hiding all new un-identified actions
// under the assumption that the user wants all actions hidden.
if (isEverySuggestedActionFilterEnabled()) {
return super.isFiltered(path, identifier, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
if (suggestedActionsGroupList.check(protobufBufferArray).isFiltered()) {
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
return false;
}
} else {
// Feed/search identifier components.
if (matchedGroup == shelfHeader) {
// 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 (!shouldHideShortsFeedItems()) return false;
}
// Super class handles logging.
return super.isFiltered(identifier, path, protobufBufferArray, matchedGroup, contentType, contentIndex);
}
private static boolean shouldHideShortsFeedItems() {
final boolean hideHome = Settings.HIDE_SHORTS_HOME.get();
final boolean hideSubscriptions = Settings.HIDE_SHORTS_SUBSCRIPTIONS.get();
final boolean hideSearch = Settings.HIDE_SHORTS_SEARCH.get();
if (hideHome && 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 (PlayerType.getCurrent().isMaximizedOrFullscreen()) {
// 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 (NavigationBar.isSearchBarActive()) {
return hideSearch;
}
// Avoid checking navigation button status if all other Shorts should show.
if (!hideHome && !hideSubscriptions) {
return false;
}
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 must be in the library tab. Don't hide the history or any playlists here.
return false;
}
public static void hideShortsShelf(final View shortsShelfView) {
if (shouldHideShortsFeedItems()) {
Utils.hideViewByLayoutParams(shortsShelfView);
}
}
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) {
// Cannot set the visibility to gone for like/dislike,
// as some other unknown YT code also sets the visibility after this hook.
//
// Setting the view to 0dp works, but that leaves a blank space where
// the button was (only relevant for dislikes button).
//
// Instead remove the view from the parent.
Utils.hideViewByRemovingFromParentUnderCondition(Settings.HIDE_SHORTS_LIKE_BUTTON, likeButtonView);
}
public static void hideDislikeButton(final View dislikeButtonView) {
Utils.hideViewByRemovingFromParentUnderCondition(Settings.HIDE_SHORTS_DISLIKE_BUTTON, dislikeButtonView);
}
public static void hideShortsCommentsButton(final View commentsButtonView) {
hideViewUnderCondition(Settings.HIDE_SHORTS_COMMENTS_BUTTON, commentsButtonView);
}
public static void hideShortsRemixButton(final View remixButtonView) {
hideViewUnderCondition(Settings.HIDE_SHORTS_REMIX_BUTTON, remixButtonView);
}
public static void hideShortsShareButton(final View shareButtonView) {
hideViewUnderCondition(Settings.HIDE_SHORTS_SHARE_BUTTON, shareButtonView);
}
// endregion
public static void setNavigationBar(PivotBar view) {
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 int getNavigationBarHeight(int original) {
if (HIDE_SHORTS_NAVIGATION_BAR) {
return HIDDEN_NAVIGATION_BAR_VERTICAL_HEIGHT;
}
return original;
}
}

View File

@ -0,0 +1,30 @@
package app.revanced.extension.youtube.patches.components;
import androidx.annotation.Nullable;
import app.revanced.extension.youtube.patches.playback.quality.RestoreOldVideoQualityMenuPatch;
import app.revanced.extension.youtube.settings.Settings;
/**
* Abuse LithoFilter for {@link RestoreOldVideoQualityMenuPatch}.
*/
public final class VideoQualityMenuFilterPatch 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 VideoQualityMenuFilterPatch() {
addPathCallbacks(new StringFilterGroup(
Settings.RESTORE_OLD_VIDEO_QUALITY_MENU,
"quick_quality_sheet_content.eml-js"
));
}
@Override
boolean isFiltered(@Nullable String identifier, String path, byte[] protobufBufferArray,
StringFilterGroup matchedGroup, FilterContentType contentType, int contentIndex) {
isVideoQualityMenuVisible = true;
return false;
}
}

View File

@ -0,0 +1,167 @@
package app.revanced.extension.youtube.patches.playback.quality;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.shared.Utils.NetworkType;
import androidx.annotation.Nullable;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.IntegerSetting;
import app.revanced.extension.youtube.patches.VideoInformation;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class RememberVideoQualityPatch {
private static final int AUTOMATIC_VIDEO_QUALITY_VALUE = -2;
private static final IntegerSetting wifiQualitySetting = Settings.VIDEO_QUALITY_DEFAULT_WIFI;
private static final IntegerSetting mobileQualitySetting = Settings.VIDEO_QUALITY_DEFAULT_MOBILE;
private static boolean qualityNeedsUpdating;
/**
* If the user selected a new quality from the flyout menu,
* and {@link Settings#REMEMBER_VIDEO_QUALITY_LAST_SELECTED} is enabled.
*/
private static boolean userChangedDefaultQuality;
/**
* Index of the video quality chosen by the user from the flyout menu.
*/
private static int userSelectedQualityIndex;
/**
* The available qualities of the current video in human readable form: [1080, 720, 480]
*/
@Nullable
private static List<Integer> videoQualities;
private static void changeDefaultQuality(int defaultQuality) {
String networkTypeMessage;
if (Utils.getNetworkType() == NetworkType.MOBILE) {
mobileQualitySetting.save(defaultQuality);
networkTypeMessage = str("revanced_remember_video_quality_mobile");
} else {
wifiQualitySetting.save(defaultQuality);
networkTypeMessage = str("revanced_remember_video_quality_wifi");
}
Utils.showToastShort(
str("revanced_remember_video_quality_toast", networkTypeMessage, (defaultQuality + "p")));
}
/**
* Injection point.
*
* @param qualities Video qualities available, ordered from largest to smallest, with index 0 being the 'automatic' value of -2
* @param originalQualityIndex quality index to use, as chosen by YouTube
*/
public static int setVideoQuality(Object[] qualities, final int originalQualityIndex, Object qInterface, String qIndexMethod) {
try {
final int preferredQuality = Utils.getNetworkType() == NetworkType.MOBILE
? mobileQualitySetting.get()
: wifiQualitySetting.get();
if (!userChangedDefaultQuality && preferredQuality == AUTOMATIC_VIDEO_QUALITY_VALUE) {
return originalQualityIndex; // Nothing to do.
}
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));
}
}
}
// After changing videos the qualities can initially be for the prior video.
// So if the qualities have changed an update is needed.
qualityNeedsUpdating = true;
Logger.printDebug(() -> "VideoQualities: " + videoQualities);
}
if (userChangedDefaultQuality) {
userChangedDefaultQuality = false;
final int quality = videoQualities.get(userSelectedQualityIndex);
Logger.printDebug(() -> "User changed default quality to: " + quality);
changeDefaultQuality(quality);
return userSelectedQualityIndex;
}
if (!qualityNeedsUpdating) {
return originalQualityIndex;
}
qualityNeedsUpdating = false;
// Find the highest quality that is equal to or less than the preferred.
int qualityToUse = videoQualities.get(0); // first element is automatic mode
int qualityIndexToUse = 0;
int i = 0;
for (Integer quality : videoQualities) {
if (quality <= preferredQuality && qualityToUse < quality) {
qualityToUse = quality;
qualityIndexToUse = i;
}
i++;
}
// If the desired quality index is equal to the original index,
// then the video is already set to the desired default quality.
final int qualityToUseFinal = qualityToUse;
if (qualityIndexToUse == originalQualityIndex) {
// On first load of a new video, if the UI video quality flyout menu
// is not updated then it will still show 'Auto' (ie: Auto (480p)),
// even though it's already set to the desired resolution.
//
// To prevent confusion, set the video index anyways (even if it matches the existing index)
// as that will force the UI picker to not display "Auto".
Logger.printDebug(() -> "Video is already preferred quality: " + qualityToUseFinal);
} else {
Logger.printDebug(() -> "Changing video quality from: "
+ videoQualities.get(originalQualityIndex) + " to: " + qualityToUseFinal);
}
Method m = qInterface.getClass().getMethod(qIndexMethod, Integer.TYPE);
m.invoke(qInterface, qualityToUse);
return qualityIndexToUse;
} catch (Exception ex) {
Logger.printException(() -> "Failed to set quality", ex);
return originalQualityIndex;
}
}
/**
* Injection point. Old quality menu.
*/
public static void userChangedQuality(int selectedQualityIndex) {
if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED.get()) return;
userSelectedQualityIndex = selectedQualityIndex;
userChangedDefaultQuality = true;
}
/**
* Injection point. New quality menu.
*/
public static void userChangedQualityInNewFlyout(int selectedQuality) {
if (!Settings.REMEMBER_VIDEO_QUALITY_LAST_SELECTED.get()) return;
changeDefaultQuality(selectedQuality); // Quality is human readable resolution (ie: 1080).
}
/**
* Injection point.
*/
public static void newVideoStarted(VideoInformation.PlaybackController ignoredPlayerController) {
Logger.printDebug(() -> "newVideoStarted");
qualityNeedsUpdating = true;
videoQualities = null;
}
}

View File

@ -0,0 +1,110 @@
package app.revanced.extension.youtube.patches.playback.quality;
import android.support.v7.widget.RecyclerView;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.widget.ListView;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.patches.components.VideoQualityMenuFilterPatch;
import app.revanced.extension.youtube.settings.Settings;
/**
* This patch contains the logic to show the old video quality menu.
* Two methods are required, because the quality menu is a RecyclerView in the new YouTube version
* and a ListView in the old one.
*/
@SuppressWarnings("unused")
public final class RestoreOldVideoQualityMenuPatch {
/**
* Injection point.
*/
public static void onFlyoutMenuCreate(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 (!VideoQualityMenuFilterPatch.isVideoQualityMenuVisible || recyclerView.getChildCount() == 0) {
return;
}
VideoQualityMenuFilterPatch.isVideoQualityMenuVisible = false;
ViewParent quickQualityViewParent = Utils.getParentView(recyclerView, 3);
if (!(quickQualityViewParent instanceof ViewGroup)) {
return;
}
View firstChild = recyclerView.getChildAt(0);
if (!(firstChild instanceof ViewGroup)) {
return;
}
ViewGroup advancedQualityParentView = (ViewGroup) firstChild;
if (advancedQualityParentView.getChildCount() < 4) {
return;
}
View advancedQualityView = advancedQualityParentView.getChildAt(3);
if (advancedQualityView == null) {
return;
}
((ViewGroup) quickQualityViewParent).setVisibility(View.GONE);
// Click the "Advanced" quality menu to show the "old" quality menu.
advancedQualityView.setSoundEffectsEnabled(false);
advancedQualityView.performClick();
} catch (Exception ex) {
Logger.printException(() -> "onFlyoutMenuCreate failure", ex);
}
});
}
/**
* Injection point.
*
* Used to force the creation of the advanced menu item for the Shorts quality flyout.
*/
public static boolean forceAdvancedVideoQualityMenuCreation(boolean original) {
return Settings.RESTORE_OLD_VIDEO_QUALITY_MENU.get() || original;
}
/**
* Injection point.
*
* Used if spoofing to an old app version, and also used for the Shorts video quality flyout.
*/
public static void showOldVideoQualityMenu(final ListView listView) {
if (!Settings.RESTORE_OLD_VIDEO_QUALITY_MENU.get()) return;
listView.setOnHierarchyChangeListener(new ViewGroup.OnHierarchyChangeListener() {
@Override
public void onChildViewAdded(View parent, View child) {
try {
parent.setVisibility(View.GONE);
final var indexOfAdvancedQualityMenuItem = 4;
if (listView.indexOfChild(child) != indexOfAdvancedQualityMenuItem) return;
Logger.printDebug(() -> "Found advanced menu item in old type of quality menu");
listView.setSoundEffectsEnabled(false);
final var qualityItemMenuPosition = 4;
listView.performItemClick(null, qualityItemMenuPosition, 0);
} catch (Exception ex) {
Logger.printException(() -> "showOldVideoQualityMenu failure", ex);
}
}
@Override
public void onChildViewRemoved(View parent, View child) {
}
});
}
}

View File

@ -0,0 +1,207 @@
package app.revanced.extension.youtube.patches.playback.speed;
import static app.revanced.extension.shared.StringRef.sf;
import static app.revanced.extension.shared.StringRef.str;
import android.preference.ListPreference;
import android.support.v7.widget.RecyclerView;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import androidx.annotation.NonNull;
import java.util.Arrays;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.patches.components.PlaybackSpeedMenuFilterPatch;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class CustomPlaybackSpeedPatch {
private static final float PLAYBACK_SPEED_AUTO = Settings.PLAYBACK_SPEED_DEFAULT.defaultValue;
/**
* Maximum playback speed, exclusive value. Custom speeds must be less than this value.
* <p>
* 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 PLAYBACK_SPEED_MAXIMUM = 8;
/**
* Custom playback speeds.
*/
public static float[] customPlaybackSpeeds;
/**
* The last time the old playback menu was forcefully called.
*/
private static long lastTimeOldPlaybackMenuInvoked;
/**
* PreferenceList entries and values, of all available playback speeds.
*/
private static String[] preferenceListEntries, preferenceListEntryValues;
static {
loadCustomSpeeds();
}
private static void resetCustomSpeeds(@NonNull String toastMessage) {
Utils.showToastLong(toastMessage);
Settings.CUSTOM_PLAYBACK_SPEEDS.resetToDefault();
}
private 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];
int i = 0;
for (String speedString : speedStrings) {
final float speedFloat = Float.parseFloat(speedString);
if (speedFloat <= 0 || arrayContains(customPlaybackSpeeds, speedFloat)) {
throw new IllegalArgumentException();
}
if (speedFloat >= PLAYBACK_SPEED_MAXIMUM) {
resetCustomSpeeds(str("revanced_custom_playback_speeds_invalid", PLAYBACK_SPEED_MAXIMUM));
loadCustomSpeeds();
return;
}
customPlaybackSpeeds[i] = speedFloat;
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;
}
/**
* Initialize a settings preference list with the available playback speeds.
*/
@SuppressWarnings("deprecation")
public static void initializeListPreference(ListPreference preference) {
if (preferenceListEntries == null) {
final int numberOfEntries = customPlaybackSpeeds.length + 1;
preferenceListEntries = new String[numberOfEntries];
preferenceListEntryValues = new String[numberOfEntries];
// Auto speed (same behavior as unpatched).
preferenceListEntries[0] = sf("revanced_custom_playback_speeds_auto").toString();
preferenceListEntryValues[0] = String.valueOf(PLAYBACK_SPEED_AUTO);
int i = 1;
for (float speed : customPlaybackSpeeds) {
String speedString = String.valueOf(speed);
preferenceListEntries[i] = speedString + "x";
preferenceListEntryValues[i] = speedString;
i++;
}
}
preference.setEntries(preferenceListEntries);
preference.setEntryValues(preferenceListEntryValues);
}
/**
* Injection point.
*/
public static void onFlyoutMenuCreate(RecyclerView recyclerView) {
recyclerView.getViewTreeObserver().addOnDrawListener(() -> {
try {
if (PlaybackSpeedMenuFilterPatch.isPlaybackRateSelectorMenuVisible) {
if (hideLithoMenuAndShowOldSpeedMenu(recyclerView, 5)) {
PlaybackSpeedMenuFilterPatch.isPlaybackRateSelectorMenuVisible = false;
}
return;
}
} catch (Exception ex) {
Logger.printException(() -> "isPlaybackRateSelectorMenuVisible failure", ex);
}
try {
if (PlaybackSpeedMenuFilterPatch.isOldPlaybackSpeedMenuVisible) {
if (hideLithoMenuAndShowOldSpeedMenu(recyclerView, 8)) {
PlaybackSpeedMenuFilterPatch.isOldPlaybackSpeedMenuVisible = false;
}
}
} catch (Exception ex) {
Logger.printException(() -> "isOldPlaybackSpeedMenuVisible failure", ex);
}
});
}
private static boolean hideLithoMenuAndShowOldSpeedMenu(RecyclerView recyclerView, int expectedChildCount) {
if (recyclerView.getChildCount() == 0) {
return false;
}
View firstChild = recyclerView.getChildAt(0);
if (!(firstChild instanceof ViewGroup PlaybackSpeedParentView)) {
return false;
}
if (PlaybackSpeedParentView.getChildCount() != expectedChildCount) {
return false;
}
ViewParent parentView3rd = Utils.getParentView(recyclerView, 3);
if (!(parentView3rd instanceof ViewGroup)) {
return true;
}
ViewParent parentView4th = parentView3rd.getParent();
if (!(parentView4th instanceof ViewGroup)) {
return true;
}
// Dismiss View [R.id.touch_outside] is the 1st ChildView of the 4th ParentView.
// This only shows in phone layout.
final var touchInsidedView = ((ViewGroup) parentView4th).getChildAt(0);
touchInsidedView.setSoundEffectsEnabled(false);
touchInsidedView.performClick();
// In tablet layout there is no Dismiss View, instead we just hide all two parent views.
((ViewGroup) parentView3rd).setVisibility(View.GONE);
((ViewGroup) parentView4th).setVisibility(View.GONE);
// Close the litho speed menu and show the old one.
showOldPlaybackSpeedMenu();
return true;
}
public static void showOldPlaybackSpeedMenu() {
// 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) {
Logger.printDebug(() -> "Ignoring call to showOldPlaybackSpeedMenu");
return;
}
lastTimeOldPlaybackMenuInvoked = now;
Logger.printDebug(() -> "Old video quality menu shown");
// Rest of the implementation added by patch.
}
}

View File

@ -0,0 +1,86 @@
package app.revanced.extension.youtube.patches.playback.speed;
import static app.revanced.extension.shared.StringRef.str;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.patches.VideoInformation;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class RememberPlaybackSpeedPatch {
private static final long TOAST_DELAY_MILLISECONDS = 750;
private static volatile boolean newVideoStarted;
private static long lastTimeSpeedChanged;
/**
* Injection point.
*/
public static void newVideoStarted(VideoInformation.PlaybackController ignoredPlayerController) {
Logger.printDebug(() -> "newVideoStarted");
newVideoStarted = true;
}
/**
* Injection point.
* Called when user selects a playback speed.
*
* @param playbackSpeed The playback speed the user selected
*/
public static void userSelectedPlaybackSpeed(float playbackSpeed) {
try {
if (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.PLAYBACK_SPEED_MAXIMUM - 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.PLAYBACK_SPEED_DEFAULT.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.PLAYBACK_SPEED_DEFAULT.save(finalPlaybackSpeed);
Utils.showToastLong(str("revanced_remember_playback_speed_toast", (finalPlaybackSpeed + "x")));
}, TOAST_DELAY_MILLISECONDS);
}
} catch (Exception ex) {
Logger.printException(() -> "userSelectedPlaybackSpeed failure", ex);
}
}
/**
* Injection point.
* Overrides the video speed. Called after video loads,
* and immediately after the user selects a different playback speed.
*/
public static float getPlaybackSpeedOverride() {
if (newVideoStarted) {
newVideoStarted = false;
final float defaultSpeed = Settings.PLAYBACK_SPEED_DEFAULT.get();
if (defaultSpeed > 0) {
return defaultSpeed;
}
}
return -2.0f;
}
}

View File

@ -0,0 +1,105 @@
package app.revanced.extension.youtube.patches.spoof;
import static app.revanced.extension.youtube.patches.spoof.DeviceHardwareSupport.allowAV1;
import static app.revanced.extension.youtube.patches.spoof.DeviceHardwareSupport.allowVP9;
import android.os.Build;
import androidx.annotation.Nullable;
public enum ClientType {
// Specific purpose for age restricted, or private videos, because the iOS client is not logged in.
ANDROID_VR(28,
"Quest 3",
"12",
"com.google.android.apps.youtube.vr.oculus/1.56.21 (Linux; U; Android 12; GB) gzip",
"32", // Android 12.1
"1.56.21",
"ANDROID_VR",
true
),
// Specific for kids videos.
// https://dumps.tadiphone.dev/dumps/oculus/eureka
IOS(5,
// iPhone 15 supports AV1 hardware decoding.
// Only use if this Android device also has hardware decoding.
allowAV1()
? "iPhone16,2" // 15 Pro Max
: "iPhone11,4", // XS Max
// iOS 14+ forces VP9.
allowVP9()
? "17.5.1.21F90"
: "13.7.17H35",
allowVP9()
? "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 17_5_1 like Mac OS X)"
: "com.google.ios.youtube/19.10.7 (iPhone; U; CPU iOS 13_7 like Mac OS X)",
null,
// Version number should be a valid iOS release.
// https://www.ipa4fun.com/history/185230
"19.10.7",
"IOS",
false
);
/**
* YouTube
* <a href="https://github.com/zerodytrash/YouTube-Internal-Clients?tab=readme-ov-file#clients">client type</a>
*/
public final int id;
/**
* Device model, equivalent to {@link Build#MODEL} (System property: ro.product.model)
*/
public final String deviceModel;
/**
* 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.
*/
@Nullable
public final String androidSdkVersion;
/**
* Client name.
*/
public final String clientName;
/**
* App version.
*/
public final String clientVersion;
/**
* If the client can access the API logged in.
*/
public final boolean canLogin;
ClientType(int id,
String deviceModel,
String osVersion,
String userAgent,
@Nullable String androidSdkVersion,
String clientVersion,
String clientName,
boolean canLogin
) {
this.id = id;
this.deviceModel = deviceModel;
this.osVersion = osVersion;
this.userAgent = userAgent;
this.androidSdkVersion = androidSdkVersion;
this.clientVersion = clientVersion;
this.clientName = clientName;
this.canLogin = canLogin;
}
}

View File

@ -0,0 +1,53 @@
package app.revanced.extension.youtube.patches.spoof;
import android.media.MediaCodecInfo;
import android.media.MediaCodecList;
import android.os.Build;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.youtube.settings.Settings;
public class DeviceHardwareSupport {
public static final boolean DEVICE_HAS_HARDWARE_DECODING_VP9;
public 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 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q;
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_VIDEO_STREAMS_IOS_FORCE_AVC.get();
}
public static boolean allowAV1() {
return allowVP9() && DEVICE_HAS_HARDWARE_DECODING_AV1;
}
}

View File

@ -0,0 +1,23 @@
package app.revanced.extension.youtube.patches.spoof;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class SpoofAppVersionPatch {
private static final boolean SPOOF_APP_VERSION_ENABLED = Settings.SPOOF_APP_VERSION.get();
private static final String SPOOF_APP_VERSION_TARGET = Settings.SPOOF_APP_VERSION_TARGET.get();
/**
* Injection point
*/
public static String getYouTubeVersionOverride(String version) {
if (SPOOF_APP_VERSION_ENABLED) return SPOOF_APP_VERSION_TARGET;
return version;
}
public static boolean isSpoofingToLessThan(String version) {
return SPOOF_APP_VERSION_ENABLED && SPOOF_APP_VERSION_TARGET.compareTo(version) < 0;
}
}

View File

@ -0,0 +1,17 @@
package app.revanced.extension.youtube.patches.spoof;
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;
}
}

View File

@ -0,0 +1,168 @@
package app.revanced.extension.youtube.patches.spoof;
import android.net.Uri;
import androidx.annotation.Nullable;
import java.nio.ByteBuffer;
import java.util.Map;
import java.util.Objects;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.youtube.patches.spoof.requests.StreamingDataRequest;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class SpoofVideoStreamsPatch {
public static final class ForceiOSAVCAvailability implements Setting.Availability {
@Override
public boolean isAvailable() {
return Settings.SPOOF_VIDEO_STREAMS.get() && Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get() == ClientType.IOS;
}
}
private static final boolean SPOOF_STREAMING_DATA = Settings.SPOOF_VIDEO_STREAMS.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.
* <p>
* 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<String, String> 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 (BaseSettings.DEBUG.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;
}
}

View File

@ -0,0 +1,74 @@
package app.revanced.extension.youtube.patches.spoof.requests;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.net.HttpURLConnection;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.requests.Requester;
import app.revanced.extension.shared.requests.Route;
import app.revanced.extension.youtube.patches.spoof.ClientType;
final class PlayerRoutes {
static final Route.CompiledRoute GET_STREAMING_DATA = new Route(
Route.Method.POST,
"player" +
"?fields=streamingData" +
"&alt=proto"
).compile();
private static final String YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/";
/**
* TCP connection and HTTP read timeout
*/
private static final int CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000; // 10 Seconds.
private PlayerRoutes() {
}
static String createInnertubeBody(ClientType clientType) {
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.androidSdkVersion != null) {
client.put("androidSdkVersion", clientType.androidSdkVersion);
}
context.put("client", client);
innerTubeBody.put("context", context);
innerTubeBody.put("contentCheckOk", true);
innerTubeBody.put("racyCheckOk", true);
innerTubeBody.put("videoId", "%s");
} 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(YT_API_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;
}
}

View File

@ -0,0 +1,224 @@
package app.revanced.extension.youtube.patches.spoof.requests;
import static app.revanced.extension.youtube.patches.spoof.requests.PlayerRoutes.GET_STREAMING_DATA;
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.net.SocketTimeoutException;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.*;
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.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.youtube.patches.spoof.ClientType;
import app.revanced.extension.youtube.settings.Settings;
/**
* Video streaming data. Fetching is tied to the behavior YT uses,
* where this class fetches the streams only when YT fetches.
* <p>
* Effectively the cache expiration of these fetches is the same as the stock app,
* since the stock app would not use expired streams and therefor
* the extension replace stream hook is called only if YT
* did use its own client streams.
*/
public class StreamingDataRequest {
private static final ClientType[] CLIENT_ORDER_TO_USE;
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String[] REQUEST_HEADER_KEYS = {
AUTHORIZATION_HEADER, // Available only to logged-in users.
"X-GOOG-API-FORMAT-VERSION",
"X-Goog-Visitor-Id"
};
/**
* 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;
private static final Map<String, StreamingDataRequest> 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.
}
});
static {
ClientType[] allClientTypes = ClientType.values();
ClientType preferredClient = Settings.SPOOF_VIDEO_STREAMS_CLIENT_TYPE.get();
CLIENT_ORDER_TO_USE = new ClientType[allClientTypes.length];
CLIENT_ORDER_TO_USE[0] = preferredClient;
int i = 1;
for (ClientType c : allClientTypes) {
if (c != preferredClient) {
CLIENT_ORDER_TO_USE[i++] = c;
}
}
}
private final String videoId;
private final Future<ByteBuffer> future;
private StreamingDataRequest(String videoId, Map<String, String> playerHeaders) {
Objects.requireNonNull(playerHeaders);
this.videoId = videoId;
this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playerHeaders));
}
public static void fetchRequest(String videoId, Map<String, String> fetchHeaders) {
// Always fetch, even if there is an existing request for the same video.
cache.put(videoId, new StreamingDataRequest(videoId, fetchHeaders));
}
@Nullable
public static StreamingDataRequest getRequestForVideoId(String videoId) {
return cache.get(videoId);
}
private static void handleConnectionError(String toastMessage, @Nullable Exception ex, boolean showToast) {
if (showToast) Utils.showToastShort(toastMessage);
Logger.printInfo(() -> toastMessage, ex);
}
@Nullable
private static HttpURLConnection send(ClientType clientType, String videoId,
Map<String, String> playerHeaders,
boolean showErrorToasts) {
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);
connection.setConnectTimeout(HTTP_TIMEOUT_MILLISECONDS);
connection.setReadTimeout(HTTP_TIMEOUT_MILLISECONDS);
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 = String.format(PlayerRoutes.createInnertubeBody(clientType), 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 connection;
handleConnectionError(clientTypeName + " not available with response code: "
+ responseCode + " message: " + connection.getResponseMessage(),
null, showErrorToasts);
} catch (SocketTimeoutException ex) {
handleConnectionError("Connection timeout", ex, showErrorToasts);
} catch (IOException ex) {
handleConnectionError("Network error", ex, showErrorToasts);
} catch (Exception ex) {
Logger.printException(() -> "send failed", ex);
} finally {
Logger.printDebug(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms");
}
return null;
}
private static ByteBuffer fetch(String videoId, Map<String, String> playerHeaders) {
final boolean debugEnabled = BaseSettings.DEBUG.get();
// Retry with different client if empty response body is received.
int i = 0;
for (ClientType clientType : CLIENT_ORDER_TO_USE) {
// Show an error if the last client type fails, or if the debug is enabled then show for all attempts.
final boolean showErrorToast = (++i == CLIENT_ORDER_TO_USE.length) || debugEnabled;
HttpURLConnection connection = send(clientType, videoId, playerHeaders, showErrorToast);
if (connection != null) {
try {
// gzip encoding doesn't response with content length (-1),
// but empty response body does.
if (connection.getContentLength() != 0) {
try (InputStream inputStream = new BufferedInputStream(connection.getInputStream())) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[2048];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) >= 0) {
baos.write(buffer, 0, bytesRead);
}
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, debugEnabled);
return null;
}
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 + '\'' + '}';
}
}

View File

@ -0,0 +1,48 @@
package app.revanced.extension.youtube.patches.theme;
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.patches.HideSeekbarPatch;
import app.revanced.extension.youtube.settings.Settings;
/**
* Used by {@link SeekbarColorPatch} change the color of the seekbar.
* and {@link HideSeekbarPatch} to hide the seekbar of the feed and watch history.
*/
@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(SeekbarColorPatch.getSeekbarColor());
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;
}
}

View File

@ -0,0 +1,260 @@
package app.revanced.extension.youtube.patches.theme;
import static app.revanced.extension.shared.StringRef.str;
import android.content.res.Resources;
import android.graphics.Color;
import android.graphics.drawable.AnimatedVectorDrawable;
import java.util.Arrays;
import java.util.Locale;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class SeekbarColorPatch {
private static final boolean SEEKBAR_CUSTOM_COLOR_ENABLED = Settings.SEEKBAR_CUSTOM_COLOR.get();
/**
* Default color of the litho seekbar.
* Differs slightly from the default custom seekbar color setting.
*/
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.
*/
private static final float ORIGINAL_SEEKBAR_COLOR_BRIGHTNESS;
/**
* If {@link Settings#SEEKBAR_CUSTOM_COLOR} is enabled,
* this is the color value of {@link Settings#SEEKBAR_CUSTOM_COLOR_VALUE}.
* Otherwise this is {@link #ORIGINAL_SEEKBAR_COLOR}.
*/
private static int seekbarColor = ORIGINAL_SEEKBAR_COLOR;
/**
* Custom seekbar hue, saturation, and brightness values.
*/
private static final float[] customSeekbarColorHSV = new float[3];
static {
float[] hsv = new float[3];
Color.colorToHSV(ORIGINAL_SEEKBAR_COLOR, hsv);
ORIGINAL_SEEKBAR_COLOR_BRIGHTNESS = hsv[2];
if (SEEKBAR_CUSTOM_COLOR_ENABLED) {
loadCustomSeekbarColor();
}
}
private static void loadCustomSeekbarColor() {
try {
seekbarColor = Color.parseColor(Settings.SEEKBAR_CUSTOM_COLOR_VALUE.get());
Color.colorToHSV(seekbarColor, customSeekbarColorHSV);
} catch (Exception ex) {
Utils.showToastShort(str("revanced_seekbar_custom_color_invalid"));
Settings.SEEKBAR_CUSTOM_COLOR_VALUE.resetToDefault();
loadCustomSeekbarColor();
}
}
public static int getSeekbarColor() {
return seekbarColor;
}
/**
* Injection point
*/
public static boolean playerSeekbarGradientEnabled(boolean original) {
if (SEEKBAR_CUSTOM_COLOR_ENABLED) return false;
return original;
}
/**
* Injection point
*/
public static boolean useLotteLaunchSplashScreen(boolean original) {
Logger.printDebug(() -> "useLotteLaunchSplashScreen original: " + original);
if (SEEKBAR_CUSTOM_COLOR_ENABLED) return false;
return original;
}
private static int colorChannelTo3Bits(int channel8Bits) {
final float channel3Bits = channel8Bits * 7 / 255f;
// If a color channel is near zero, then allow rounding up so values between
// 0x12 and 0x23 will show as 0x24. But always round down when the channel is
// near full saturation, otherwise rounding to nearest will cause all values
// between 0xEC and 0xFE to always show as full saturation (0xFF).
return channel3Bits < 6
? Math.round(channel3Bits)
: (int) channel3Bits;
}
private static String get9BitStyleIdentifier(int color24Bit) {
final int r3 = colorChannelTo3Bits(Color.red(color24Bit));
final int g3 = colorChannelTo3Bits(Color.green(color24Bit));
final int b3 = colorChannelTo3Bits(Color.blue(color24Bit));
return String.format(Locale.US, "splash_seekbar_color_style_%d_%d_%d", r3, g3, b3);
}
/**
* Injection point
*/
public static void setSplashAnimationDrawableTheme(AnimatedVectorDrawable vectorDrawable) {
// Alternatively a ColorMatrixColorFilter can be used to change the color of the drawable
// without using any styles, but a color filter cannot selectively change the seekbar
// while keeping the red YT logo untouched.
// Even if the seekbar color xml value is changed to a completely different color (such as green),
// a color filter still cannot be selectively applied when the drawable has more than 1 color.
try {
String seekbarStyle = get9BitStyleIdentifier(seekbarColor);
Logger.printDebug(() -> "Using splash seekbar style: " + seekbarStyle);
final int styleIdentifierDefault = Utils.getResourceIdentifier(
seekbarStyle,
"style"
);
if (styleIdentifierDefault == 0) {
throw new RuntimeException("Seekbar style not found: " + seekbarStyle);
}
Resources.Theme theme = Utils.getContext().getResources().newTheme();
theme.applyStyle(styleIdentifierDefault, true);
vectorDrawable.applyTheme(theme);
} catch (Exception ex) {
Logger.printException(() -> "setSplashAnimationDrawableTheme failure", ex);
}
}
/**
* Injection point.
*
* Overrides all Litho components 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 getLithoColor(int colorValue) {
if (colorValue == ORIGINAL_SEEKBAR_COLOR) {
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) {
final boolean hideSeekbar = Settings.HIDE_SEEKBAR_THUMBNAIL.get();
if (SEEKBAR_CUSTOM_COLOR_ENABLED || hideSeekbar) {
// 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, hideSeekbar
? 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;
}
/**
* Injection point.
*
* Overrides color used for the video player seekbar.
*/
public static int getVideoPlayerSeekbarColor(int originalColor) {
if (!SEEKBAR_CUSTOM_COLOR_ENABLED) {
return originalColor;
}
return getSeekbarColorValue(originalColor);
}
/**
* Color parameter is changed to the custom seekbar color, while retaining
* the brightness and alpha changes of the parameter value compared to the original seekbar color.
*/
private static int getSeekbarColorValue(int originalColor) {
try {
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.
float[] hsv = new float[3];
Color.colorToHSV(originalColor, hsv);
final float brightnessDifference = hsv[2] - ORIGINAL_SEEKBAR_COLOR_BRIGHTNESS;
// Apply the brightness difference to the custom seekbar color.
hsv[0] = customSeekbarColorHSV[0];
hsv[1] = customSeekbarColorHSV[1];
hsv[2] = clamp(customSeekbarColorHSV[2] + brightnessDifference, 0, 1);
final int replacementAlpha = clamp(Color.alpha(seekbarColor) + alphaDifference, 0, 255);
final int replacementColor = Color.HSVToColor(replacementAlpha, hsv);
Logger.printDebug(() -> String.format("Original color: #%08X replacement color: #%08X",
originalColor, replacementColor));
return replacementColor;
} catch (Exception ex) {
Logger.printException(() -> "getSeekbarColorValue failure", ex);
return originalColor;
}
}
/** @noinspection SameParameterValue */
private static int clamp(int value, int lower, int upper) {
return Math.max(lower, Math.min(value, upper));
}
/** @noinspection SameParameterValue */
private static float clamp(float value, float lower, float upper) {
return Math.max(lower, Math.min(value, upper));
}
}

View File

@ -0,0 +1,61 @@
package app.revanced.extension.youtube.patches.theme;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.ThemeHelper;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public class ThemePatch {
// color constants used in relation with litho components
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, // explore drawer 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 final int WHITE_COLOR = Utils.getResourceColor("yt_white1");
private static final int BLACK_COLOR = Utils.getResourceColor("yt_black1");
private static final boolean GRADIENT_LOADING_SCREEN_ENABLED = Settings.GRADIENT_LOADING_SCREEN.get();
/**
* Injection point.
*
* Change the color of Litho components.
* If the color of the component matches one of the values, return the background color .
*
* @param originalValue The original color value.
* @return The new or original color value
*/
public static int getValue(int originalValue) {
if (ThemeHelper.isDarkTheme()) {
if (anyEquals(originalValue, DARK_VALUES)) return BLACK_COLOR;
} else {
if (anyEquals(originalValue, WHITE_VALUES)) return WHITE_COLOR;
}
return originalValue;
}
private static boolean anyEquals(int value, int... of) {
for (int v : of) if (value == v) return true;
return false;
}
/**
* Injection point.
*/
public static boolean gradientLoadingScreenEnabled() {
return GRADIENT_LOADING_SCREEN_ENABLED;
}
}

View File

@ -0,0 +1,730 @@
package app.revanced.extension.youtube.returnyoutubedislike;
import static app.revanced.extension.shared.StringRef.str;
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.os.Build;
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.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.*;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.youtube.ThemeHelper;
import app.revanced.extension.youtube.patches.spoof.SpoofAppVersionPatch;
import app.revanced.extension.youtube.returnyoutubedislike.requests.RYDVoteData;
import app.revanced.extension.youtube.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.shared.PlayerType;
/**
* 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 {
public enum Vote {
LIKE(1),
DISLIKE(-1),
LIKE_REMOVE(0);
public final int value;
Vote(int value) {
this.value = value;
}
}
/**
* 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:
* https://developer.android.com/topic/performance/vitals/anr
*/
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'
private static final boolean IS_SPOOFING_TO_OLD_SEPARATOR_COLOR
= SpoofAppVersionPatch.isSpoofingToLessThan("18.10.00");
/**
* Cached lookup of all video ids.
*/
@GuardedBy("itself")
private static final Map<String, ReturnYouTubeDislike> 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;
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, 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);
}
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<RYDVoteData> 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 ThemeHelper.isDarkTheme()
? 0x29AAAAAA // transparent dark gray
: 0xFFD9D9D9; // light gray
}
return ThemeHelper.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)) {
// 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());
}
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) {
return (SpannableString) sourceStyle; // 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
if (dislikeCountFormatter == null) {
Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale;
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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
synchronized (ReturnYouTubeDislike.class) { // number formatter is not thread safe, must synchronize
if (dislikePercentageFormatter == null) {
Locale locale = Objects.requireNonNull(Utils.getContext()).getResources().getConfiguration().locale;
dislikePercentageFormatter = NumberFormat.getPercentInstance(locale);
// Want to set the digit strings, and the simplest way is to cast to the implementation NumberFormat returns.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
&& dislikePercentageFormatter instanceof DecimalFormat) {
DecimalFormatSymbols symbols = DecimalFormatSymbols.getInstance(locale);
symbols.setDigitStrings(DecimalFormatSymbols.getInstance(Locale.ENGLISH).getDigitStrings());
((DecimalFormat) dislikePercentageFormatter).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.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
final long now = System.currentTimeMillis();
fetchCache.values().removeIf(value -> {
final boolean expired = value.isExpired(now);
if (expired)
Logger.printDebug(() -> "Removing expired fetch: " + value.videoId);
return expired;
});
}
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;
}
@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;
}
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 {
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.
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(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();
}
}

View File

@ -0,0 +1,179 @@
package app.revanced.extension.youtube.returnyoutubedislike.requests;
import static app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike.Vote;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.json.JSONException;
import org.json.JSONObject;
import app.revanced.extension.shared.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.
*/
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)
}

View File

@ -0,0 +1,614 @@
package app.revanced.extension.youtube.returnyoutubedislike.requests;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.youtube.returnyoutubedislike.requests.ReturnYouTubeDislikeRoutes.getRYDConnectionFromRoute;
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.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.requests.Requester;
import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike;
import app.revanced.extension.youtube.settings.Settings;
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;
/**
* 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;
/**
* Number of times {@link #HTTP_STATUS_CODE_RATE_LIMIT} was requested by RYD api.
* Does not include network calls attempted while rate limit is in effect,
* and does not include rate limit imposed if a fetch fails.
*/
private static volatile int numberOfRateLimitRequestsEncountered;
/**
* Number of network calls made in {@link #fetchVotes(String)}
*/
private static volatile int fetchCallCount;
/**
* Number of times {@link #fetchVotes(String)} failed due to timeout or any other error.
* This does not include when rate limit requests are encountered.
*/
private static volatile int fetchCallNumberOfFailures;
/**
* Total time spent waiting for {@link #fetchVotes(String)} network call to complete.
* Value does does not persist on app shut down.
*/
private static volatile long fetchCallResponseTimeTotal;
/**
* Round trip network time for the most recent call to {@link #fetchVotes(String)}
*/
private static volatile long fetchCallResponseTimeLast;
private static volatile long fetchCallResponseTimeMin;
private static volatile long fetchCallResponseTimeMax;
public static final int FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT = -1;
/**
* If rate limit was hit, this returns {@link #FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT}
*/
public static long getFetchCallResponseTimeLast() {
return fetchCallResponseTimeLast;
}
public static long getFetchCallResponseTimeMin() {
return fetchCallResponseTimeMin;
}
public static long getFetchCallResponseTimeMax() {
return fetchCallResponseTimeMax;
}
public static long getFetchCallResponseTimeAverage() {
return fetchCallCount == 0 ? 0 : (fetchCallResponseTimeTotal / fetchCallCount);
}
public static int getFetchCallCount() {
return fetchCallCount;
}
public static int getFetchCallNumberOfFailures() {
return fetchCallNumberOfFailures;
}
public static int getNumberOfRateLimitRequestsEncountered() {
return numberOfRateLimitRequestsEncountered;
}
private ReturnYouTubeDislikeApi() {
} // utility class
/**
* Simulates a slow response by doing meaningless calculations.
* Used to debug the app UI and verify UI timeout logic works
*/
private static void randomlyWaitIfLocallyDebugging() {
final boolean DEBUG_RANDOMLY_DELAY_NETWORK_CALLS = false; // set true to debug UI
//noinspection ConstantValue
if (DEBUG_RANDOMLY_DELAY_NETWORK_CALLS) {
final long amountOfTimeToWaste = (long) (Math.random()
* (API_GET_VOTES_TCP_TIMEOUT_MILLISECONDS + API_GET_VOTES_HTTP_TIMEOUT_MILLISECONDS));
Utils.doNothingForDuration(amountOfTimeToWaste);
}
}
/**
* 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) {
final boolean DEBUG_RATE_LIMIT = false; // set to true, to verify rate limit works
//noinspection ConstantValue
if (DEBUG_RATE_LIMIT) {
final double RANDOM_RATE_LIMIT_PERCENTAGE = 0.2; // 20% chance of a triggering a rate limit
if (Math.random() < RANDOM_RATE_LIMIT_PERCENTAGE) {
Logger.printDebug(() -> "Artificially triggering rate limit for debug purposes");
httpResponseCode = HTTP_STATUS_CODE_RATE_LIMIT;
}
}
return httpResponseCode == HTTP_STATUS_CODE_RATE_LIMIT;
}
@SuppressWarnings("NonAtomicOperationOnVolatileField") // Don't care, fields are only estimates.
private static void updateRateLimitAndStats(long timeNetworkCallStarted, boolean connectionError, boolean rateLimitHit) {
if (connectionError && rateLimitHit) {
throw new IllegalArgumentException();
}
final long responseTimeOfFetchCall = System.currentTimeMillis() - timeNetworkCallStarted;
fetchCallResponseTimeTotal += responseTimeOfFetchCall;
fetchCallResponseTimeMin = (fetchCallResponseTimeMin == 0) ? responseTimeOfFetchCall : Math.min(responseTimeOfFetchCall, fetchCallResponseTimeMin);
fetchCallResponseTimeMax = Math.max(responseTimeOfFetchCall, fetchCallResponseTimeMax);
fetchCallCount++;
if (connectionError) {
timeToResumeAPICalls = System.currentTimeMillis() + BACKOFF_CONNECTION_ERROR_MILLISECONDS;
fetchCallResponseTimeLast = responseTimeOfFetchCall;
fetchCallNumberOfFailures++;
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;
numberOfRateLimitRequestsEncountered++;
fetchCallResponseTimeLast = FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT;
if (!lastApiCallFailed && Settings.RYD_TOAST_ON_CONNECTION_ERROR.get()) {
Utils.showToastLong(str("revanced_ryd_failure_client_rate_limit_requested"));
}
lastApiCallFailed = true;
} else {
fetchCallResponseTimeLast = responseTimeOfFetchCall;
lastApiCallFailed = false;
}
}
private static void handleConnectionError(@NonNull String toastMessage,
@Nullable Exception ex,
boolean showLongToast) {
if (!lastApiCallFailed && Settings.RYD_TOAST_ON_CONNECTION_ERROR.get()) {
if (showLongToast) {
Utils.showToastLong(toastMessage);
} else {
Utils.showToastShort(toastMessage);
}
}
lastApiCallFailed = true;
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);
final long timeNetworkCallStarted = System.currentTimeMillis();
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
randomlyWaitIfLocallyDebugging();
final int responseCode = connection.getResponseCode();
if (checkIfRateLimitWasHit(responseCode)) {
connection.disconnect(); // rate limit hit, should disconnect
updateRateLimitAndStats(timeNetworkCallStarted, 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(timeNetworkCallStarted, 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 {
// Unexpected response code. Most likely RYD is temporarily broken.
handleConnectionError(str("revanced_ryd_failure_connection_status_code", responseCode),
null, true);
}
connection.disconnect(); // Something went wrong, might as well disconnect.
} catch (SocketTimeoutException ex) {
handleConnectionError((str("revanced_ryd_failure_connection_timeout")), ex, false);
} catch (IOException ex) {
handleConnectionError((str("revanced_ryd_failure_generic", ex.getMessage())), ex, true);
} catch (Exception ex) {
// should never happen
Logger.printException(() -> "fetchVotes failure", ex);
}
updateRateLimitAndStats(timeNetworkCallStarted, 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(36);
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, true);
connection.disconnect();
} catch (SocketTimeoutException ex) {
handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false);
} catch (IOException ex) {
handleConnectionError(str("revanced_ryd_failure_generic", "registration failed"), ex, true);
} 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, true);
} catch (SocketTimeoutException ex) {
handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false);
} catch (IOException ex) {
handleConnectionError(str("revanced_ryd_failure_generic", "confirm registration failed"),
ex, true);
} catch (Exception ex) {
Logger.printException(() -> "Failed to confirm registration for user: " + userId
+ "solution: " + solution, ex);
}
return 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 = registerAsNewUser();
if (userId != null) {
Settings.RYD_USER_ID.save(userId);
}
return userId;
}
public static boolean sendVote(String videoId, ReturnYouTubeDislike.Vote vote) {
Utils.verifyOffMainThread();
Objects.requireNonNull(videoId);
Objects.requireNonNull(vote);
try {
String userId = getUserId();
if (userId == null) return false;
if (checkIfRateLimitInEffect("sendVote")) {
return false;
}
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 false;
}
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 confirmVote(videoId, userId, solution);
}
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, true);
connection.disconnect(); // something went wrong, might as well disconnect
} catch (SocketTimeoutException ex) {
handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false);
} catch (IOException ex) {
handleConnectionError(str("revanced_ryd_failure_generic", "send vote failed"), ex, true);
} catch (Exception ex) {
// should never happen
Logger.printException(() -> "Failed to send vote for video: " + videoId + " vote: " + vote, ex);
}
return false;
}
private static boolean confirmVote(String videoId, String userId, String solution) {
Utils.verifyOffMainThread();
Objects.requireNonNull(videoId);
Objects.requireNonNull(userId);
Objects.requireNonNull(solution);
try {
if (checkIfRateLimitInEffect("confirmVote")) {
return false;
}
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 false;
}
if (responseCode == HTTP_STATUS_CODE_SUCCESS) {
Logger.printDebug(() -> "Vote confirm successful for video: " + videoId);
return true;
}
// 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, true);
} catch (SocketTimeoutException ex) {
handleConnectionError(str("revanced_ryd_failure_connection_timeout"), ex, false);
} catch (IOException ex) {
handleConnectionError(str("revanced_ryd_failure_generic", "confirm vote failed"),
ex, true);
} catch (Exception ex) {
Logger.printException(() -> "Failed to confirm vote for video: " + videoId
+ " solution: " + solution, ex); // should never happen
}
return false;
}
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) {
final long timeSolveStarted = System.currentTimeMillis();
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) {
String solution = Base64.encodeToString(new byte[]{buffer[0], buffer[1], buffer[2], buffer[3]}, Base64.NO_WRAP);
Logger.printDebug(() -> "Found puzzle solution: " + solution + " of difficulty: " + difficulty
+ " in: " + (System.currentTimeMillis() - timeSolveStarted) + " ms");
return solution;
}
}
// should never be reached
throw new IllegalStateException("Failed to solve puzzle challenge: " + challenge + " difficulty: " + difficulty);
}
// https://stackoverflow.com/a/157202
private static String randomString(int len) {
String AB = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
SecureRandom rnd = new SecureRandom();
StringBuilder sb = new StringBuilder(len);
for (int i = 0; i < len; i++)
sb.append(AB.charAt(rnd.nextInt(AB.length())));
return sb.toString();
}
private static int countLeadingZeroes(byte[] uInt8View) {
int zeroes = 0;
for (byte b : uInt8View) {
int 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;
}
}

View File

@ -0,0 +1,28 @@
package app.revanced.extension.youtube.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;
class ReturnYouTubeDislikeRoutes {
static final String RYD_API_URL = "https://returnyoutubedislikeapi.com/";
static final Route SEND_VOTE = new Route(POST, "interact/vote");
static final Route CONFIRM_VOTE = new Route(POST, "interact/confirmVote");
static final Route GET_DISLIKES = new Route(GET, "votes?videoId={video_id}");
static final Route GET_REGISTRATION = new Route(GET, "puzzle/registration?userId={user_id}");
static final Route CONFIRM_REGISTRATION = new Route(POST, "puzzle/registration?userId={user_id}");
private ReturnYouTubeDislikeRoutes() {
}
static HttpURLConnection getRYDConnectionFromRoute(Route route, String... params) throws IOException {
return Requester.getConnectionFromRoute(RYD_API_URL, route, params);
}
}

View File

@ -0,0 +1,97 @@
package app.revanced.extension.youtube.settings;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.preference.PreferenceFragment;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.TextView;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.youtube.ThemeHelper;
import app.revanced.extension.youtube.settings.preference.ReVancedPreferenceFragment;
import app.revanced.extension.youtube.settings.preference.ReturnYouTubeDislikePreferenceFragment;
import app.revanced.extension.youtube.settings.preference.SponsorBlockPreferenceFragment;
import java.util.Objects;
import static app.revanced.extension.shared.Utils.getChildView;
import static app.revanced.extension.shared.Utils.getResourceIdentifier;
/**
* Hooks LicenseActivity.
* <p>
* This class is responsible for injecting our own fragment by replacing the LicenseActivity.
*/
@SuppressWarnings("unused")
public class LicenseActivityHook {
/**
* Injection point.
* <p>
* Hooks LicenseActivity#onCreate in order to inject our own fragment.
*/
public static void initialize(Activity licenseActivity) {
try {
ThemeHelper.setActivityTheme(licenseActivity);
licenseActivity.setContentView(
getResourceIdentifier("revanced_settings_with_toolbar", "layout"));
setBackButton(licenseActivity);
PreferenceFragment fragment;
String toolbarTitleResourceName;
String dataString = Objects.requireNonNull(licenseActivity.getIntent().getDataString());
switch (dataString) {
case "revanced_sb_settings_intent":
toolbarTitleResourceName = "revanced_sb_settings_title";
fragment = new SponsorBlockPreferenceFragment();
break;
case "revanced_ryd_settings_intent":
toolbarTitleResourceName = "revanced_ryd_settings_title";
fragment = new ReturnYouTubeDislikePreferenceFragment();
break;
case "revanced_settings_intent":
toolbarTitleResourceName = "revanced_settings_title";
fragment = new ReVancedPreferenceFragment();
break;
default:
Logger.printException(() -> "Unknown setting: " + dataString);
return;
}
setToolbarTitle(licenseActivity, toolbarTitleResourceName);
//noinspection deprecation
licenseActivity.getFragmentManager()
.beginTransaction()
.replace(getResourceIdentifier("revanced_settings_fragments", "id"), fragment)
.commit();
} catch (Exception ex) {
Logger.printException(() -> "initialize failure", ex);
}
}
private static void setToolbarTitle(Activity activity, String toolbarTitleResourceName) {
ViewGroup toolbar = activity.findViewById(getToolbarResourceId());
TextView toolbarTextView = Objects.requireNonNull(getChildView(toolbar, false,
view -> view instanceof TextView));
toolbarTextView.setText(getResourceIdentifier(toolbarTitleResourceName, "string"));
}
@SuppressLint("UseCompatLoadingForDrawables")
private static void setBackButton(Activity activity) {
ViewGroup toolbar = activity.findViewById(getToolbarResourceId());
ImageButton imageButton = Objects.requireNonNull(getChildView(toolbar, false,
view -> view instanceof ImageButton));
imageButton.setImageDrawable(ReVancedPreferenceFragment.getBackButtonDrawable());
imageButton.setOnClickListener(view -> activity.onBackPressed());
}
private static int getToolbarResourceId() {
final int toolbarResourceId = getResourceIdentifier("revanced_toolbar", "id");
if (toolbarResourceId == 0) {
throw new IllegalStateException("Could not find back button resource");
}
return toolbarResourceId;
}
}

View File

@ -0,0 +1,400 @@
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.*;
import static app.revanced.extension.youtube.patches.ChangeStartPagePatch.StartPage;
import static app.revanced.extension.youtube.patches.MiniplayerPatch.MiniplayerHideExpandCloseAvailability;
import static app.revanced.extension.youtube.patches.MiniplayerPatch.MiniplayerHorizontalDragAvailability;
import static app.revanced.extension.youtube.patches.MiniplayerPatch.MiniplayerType;
import static app.revanced.extension.youtube.patches.MiniplayerPatch.MiniplayerType.*;
import static app.revanced.extension.youtube.patches.SeekbarThumbnailsPatch.SeekbarThumbnailsHighQualityAvailability;
import static app.revanced.extension.youtube.patches.VersionCheckPatch.IS_19_17_OR_GREATER;
import static app.revanced.extension.youtube.sponsorblock.objects.CategoryBehaviour.*;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.settings.*;
import app.revanced.extension.shared.settings.preference.SharedPrefCategory;
import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.DeArrowAvailability;
import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.StillImagesAvailability;
import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.ThumbnailOption;
import app.revanced.extension.youtube.patches.AlternativeThumbnailsPatch.ThumbnailStillTime;
import app.revanced.extension.youtube.patches.spoof.ClientType;
import app.revanced.extension.youtube.patches.spoof.SpoofVideoStreamsPatch;
import app.revanced.extension.youtube.sponsorblock.SponsorBlockSettings;
public class Settings extends BaseSettings {
// Video
public static final BooleanSetting RESTORE_OLD_VIDEO_QUALITY_MENU = new BooleanSetting("revanced_restore_old_video_quality_menu", TRUE);
public static final BooleanSetting REMEMBER_VIDEO_QUALITY_LAST_SELECTED = new BooleanSetting("revanced_remember_video_quality_last_selected", FALSE);
public static final IntegerSetting VIDEO_QUALITY_DEFAULT_WIFI = new IntegerSetting("revanced_video_quality_default_wifi", -2);
public static final IntegerSetting VIDEO_QUALITY_DEFAULT_MOBILE = new IntegerSetting("revanced_video_quality_default_mobile", -2);
// Speed
public static final BooleanSetting REMEMBER_PLAYBACK_SPEED_LAST_SELECTED = new BooleanSetting("revanced_remember_playback_speed_last_selected", FALSE);
public static final BooleanSetting CUSTOM_SPEED_MENU = new BooleanSetting("revanced_custom_speed_menu", TRUE);
public static final FloatSetting PLAYBACK_SPEED_DEFAULT = new FloatSetting("revanced_playback_speed_default", -2.0f);
public static final StringSetting CUSTOM_PLAYBACK_SPEEDS = new StringSetting("revanced_custom_playback_speeds",
"0.25\n0.5\n0.75\n0.9\n0.95\n1.0\n1.05\n1.1\n1.25\n1.5\n1.75\n2.0\n3.0\n4.0\n5.0", true);
// Ads
public static final BooleanSetting HIDE_FULLSCREEN_ADS = new BooleanSetting("revanced_hide_fullscreen_ads", TRUE);
public static final BooleanSetting HIDE_BUTTONED_ADS = new BooleanSetting("revanced_hide_buttoned_ads", TRUE);
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);
public static final BooleanSetting HIDE_HIDE_LATEST_POSTS = new BooleanSetting("revanced_hide_latest_posts_ads", TRUE);
public static final BooleanSetting HIDE_MERCHANDISE_BANNERS = new BooleanSetting("revanced_hide_merchandise_banners", TRUE);
public static final BooleanSetting HIDE_PAID_PROMOTION_LABEL = new BooleanSetting("revanced_hide_paid_promotion_label", TRUE);
public static final BooleanSetting HIDE_PRODUCTS_BANNER = new BooleanSetting("revanced_hide_products_banner", TRUE);
public static final BooleanSetting HIDE_PLAYER_STORE_SHELF = new BooleanSetting("revanced_hide_player_store_shelf", TRUE);
public static final BooleanSetting HIDE_SHOPPING_LINKS = new BooleanSetting("revanced_hide_shopping_links", TRUE);
public static final BooleanSetting HIDE_SELF_SPONSOR = new BooleanSetting("revanced_hide_self_sponsor_ads", TRUE);
public static final BooleanSetting HIDE_VIDEO_ADS = new BooleanSetting("revanced_hide_video_ads", TRUE, true);
public static final BooleanSetting HIDE_VISIT_STORE_BUTTON = new BooleanSetting("revanced_hide_visit_store_button", TRUE);
public static final BooleanSetting HIDE_WEB_SEARCH_RESULTS = new BooleanSetting("revanced_hide_web_search_results", TRUE);
// Feed
public static final BooleanSetting HIDE_ALBUM_CARDS = new BooleanSetting("revanced_hide_album_cards", FALSE, true);
public static final BooleanSetting HIDE_ARTIST_CARDS = new BooleanSetting("revanced_hide_artist_cards", FALSE);
public static final BooleanSetting HIDE_EXPANDABLE_CHIP = new BooleanSetting("revanced_hide_expandable_chip", TRUE);
public static final BooleanSetting HIDE_DOODLES = new BooleanSetting("revanced_hide_doodles", FALSE, true, "revanced_hide_doodles_user_dialog_message");
// Alternative thumbnails
public static final EnumSetting<ThumbnailOption> ALT_THUMBNAIL_HOME = new EnumSetting<>("revanced_alt_thumbnail_home", ThumbnailOption.ORIGINAL);
public static final EnumSetting<ThumbnailOption> ALT_THUMBNAIL_SUBSCRIPTIONS = new EnumSetting<>("revanced_alt_thumbnail_subscription", ThumbnailOption.ORIGINAL);
public static final EnumSetting<ThumbnailOption> ALT_THUMBNAIL_LIBRARY = new EnumSetting<>("revanced_alt_thumbnail_library", ThumbnailOption.ORIGINAL);
public static final EnumSetting<ThumbnailOption> ALT_THUMBNAIL_PLAYER = new EnumSetting<>("revanced_alt_thumbnail_player", ThumbnailOption.ORIGINAL);
public static final EnumSetting<ThumbnailOption> 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", TRUE, new DeArrowAvailability());
public static final EnumSetting<ThumbnailStillTime> 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());
// Hide keyword content
public static final BooleanSetting HIDE_KEYWORD_CONTENT_HOME = new BooleanSetting("revanced_hide_keyword_content_home", FALSE);
public static final BooleanSetting HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_keyword_content_subscriptions", FALSE);
public static final BooleanSetting HIDE_KEYWORD_CONTENT_SEARCH = new BooleanSetting("revanced_hide_keyword_content_search", FALSE);
public static final StringSetting HIDE_KEYWORD_CONTENT_PHRASES = new StringSetting("revanced_hide_keyword_content_phrases", "",
parentsAny(HIDE_KEYWORD_CONTENT_HOME, HIDE_KEYWORD_CONTENT_SUBSCRIPTIONS, HIDE_KEYWORD_CONTENT_SEARCH));
// Uncategorized layout related settings. Do not add to this section, and instead move these out and categorize them.
public static final BooleanSetting DISABLE_SUGGESTED_VIDEO_END_SCREEN = new BooleanSetting("revanced_disable_suggested_video_end_screen", FALSE, true);
public static final BooleanSetting GRADIENT_LOADING_SCREEN = new BooleanSetting("revanced_gradient_loading_screen", FALSE, true);
public static final BooleanSetting HIDE_HORIZONTAL_SHELVES = new BooleanSetting("revanced_hide_horizontal_shelves", TRUE);
public static final BooleanSetting HIDE_CAPTIONS_BUTTON = new BooleanSetting("revanced_hide_captions_button", FALSE);
public static final BooleanSetting HIDE_CHANNEL_BAR = new BooleanSetting("revanced_hide_channel_bar", FALSE);
public static final BooleanSetting HIDE_CHANNEL_MEMBER_SHELF = new BooleanSetting("revanced_hide_channel_member_shelf", TRUE);
public static final BooleanSetting HIDE_CHIPS_SHELF = new BooleanSetting("revanced_hide_chips_shelf", TRUE);
public static final BooleanSetting HIDE_COMMUNITY_GUIDELINES = new BooleanSetting("revanced_hide_community_guidelines", TRUE);
public static final BooleanSetting HIDE_COMMUNITY_POSTS = new BooleanSetting("revanced_hide_community_posts", FALSE);
public static final BooleanSetting HIDE_COMPACT_BANNER = new BooleanSetting("revanced_hide_compact_banner", TRUE);
public static final BooleanSetting HIDE_CROWDFUNDING_BOX = new BooleanSetting("revanced_hide_crowdfunding_box", FALSE, true);
public static final BooleanSetting HIDE_EMERGENCY_BOX = new BooleanSetting("revanced_hide_emergency_box", TRUE);
public static final BooleanSetting HIDE_ENDSCREEN_CARDS = new BooleanSetting("revanced_hide_endscreen_cards", FALSE);
public static final BooleanSetting HIDE_FEED_SURVEY = new BooleanSetting("revanced_hide_feed_survey", TRUE);
public static final BooleanSetting HIDE_FILTER_BAR_FEED_IN_FEED = new BooleanSetting("revanced_hide_filter_bar_feed_in_feed", FALSE, true);
public static final BooleanSetting HIDE_FILTER_BAR_FEED_IN_RELATED_VIDEOS = new BooleanSetting("revanced_hide_filter_bar_feed_in_related_videos", FALSE, true);
public static final BooleanSetting HIDE_FILTER_BAR_FEED_IN_SEARCH = new BooleanSetting("revanced_hide_filter_bar_feed_in_search", FALSE, true);
public static final BooleanSetting HIDE_FLOATING_MICROPHONE_BUTTON = new BooleanSetting("revanced_hide_floating_microphone_button", TRUE, true);
public static final BooleanSetting HIDE_FULLSCREEN_PANELS = new BooleanSetting("revanced_hide_fullscreen_panels", TRUE, true);
public static final BooleanSetting HIDE_HIDE_CHANNEL_GUIDELINES = new BooleanSetting("revanced_hide_channel_guidelines", TRUE);
public static final BooleanSetting HIDE_HIDE_INFO_PANELS = new BooleanSetting("revanced_hide_info_panels", TRUE);
public static final BooleanSetting HIDE_IMAGE_SHELF = new BooleanSetting("revanced_hide_image_shelf", TRUE);
public static final BooleanSetting HIDE_INFO_CARDS = new BooleanSetting("revanced_hide_info_cards", FALSE);
public static final BooleanSetting HIDE_JOIN_MEMBERSHIP_BUTTON = new BooleanSetting("revanced_hide_join_membership_button", TRUE);
public static final BooleanSetting HIDE_SHOW_MORE_BUTTON = new BooleanSetting("revanced_hide_show_more_button", TRUE, true);
public static final BooleanSetting HIDE_MEDICAL_PANELS = new BooleanSetting("revanced_hide_medical_panels", TRUE);
public static final BooleanSetting HIDE_MIX_PLAYLISTS = new BooleanSetting("revanced_hide_mix_playlists", TRUE);
public static final BooleanSetting HIDE_MOVIES_SECTION = new BooleanSetting("revanced_hide_movies_section", TRUE);
public static final BooleanSetting HIDE_NOTIFY_ME_BUTTON = new BooleanSetting("revanced_hide_notify_me_button", TRUE);
public static final BooleanSetting HIDE_PLAYABLES = new BooleanSetting("revanced_hide_playables", TRUE);
public static final BooleanSetting HIDE_QUICK_ACTIONS = new BooleanSetting("revanced_hide_quick_actions", FALSE);
public static final BooleanSetting HIDE_RELATED_VIDEOS = new BooleanSetting("revanced_hide_related_videos", FALSE);
public static final BooleanSetting HIDE_SEARCH_RESULT_SHELF_HEADER = new BooleanSetting("revanced_hide_search_result_shelf_header", FALSE);
public static final BooleanSetting HIDE_SUBSCRIBERS_COMMUNITY_GUIDELINES = new BooleanSetting("revanced_hide_subscribers_community_guidelines", TRUE);
public static final BooleanSetting HIDE_TIMED_REACTIONS = new BooleanSetting("revanced_hide_timed_reactions", TRUE);
public static final BooleanSetting HIDE_TIMESTAMP = new BooleanSetting("revanced_hide_timestamp", FALSE);
public static final BooleanSetting HIDE_VIDEO_CHANNEL_WATERMARK = new BooleanSetting("revanced_hide_channel_watermark", TRUE);
public static final BooleanSetting HIDE_FOR_YOU_SHELF = new BooleanSetting("revanced_hide_for_you_shelf", TRUE);
public static final BooleanSetting HIDE_SEARCH_RESULT_RECOMMENDATIONS = new BooleanSetting("revanced_hide_search_result_recommendations", TRUE);
public static final IntegerSetting PLAYER_OVERLAY_OPACITY = new IntegerSetting("revanced_player_overlay_opacity",100, true);
public static final BooleanSetting PLAYER_POPUP_PANELS = new BooleanSetting("revanced_hide_player_popup_panels", FALSE);
// Player
public static final BooleanSetting DISABLE_FULLSCREEN_AMBIENT_MODE = new BooleanSetting("revanced_disable_fullscreen_ambient_mode", TRUE, true);
public static final BooleanSetting DISABLE_ROLLING_NUMBER_ANIMATIONS = new BooleanSetting("revanced_disable_rolling_number_animations", FALSE);
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_PREVIOUS_NEXT_BUTTONS = new BooleanSetting("revanced_hide_player_previous_next_buttons", FALSE, true);
private static final BooleanSetting DEPRECATED_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);
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_HORIZONTAL_DRAG = new BooleanSetting("revanced_miniplayer_horizontal_drag", FALSE, true, new MiniplayerHorizontalDragAvailability());
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
public static final BooleanSetting EXTERNAL_DOWNLOADER = new BooleanSetting("revanced_external_downloader", FALSE);
public static final BooleanSetting EXTERNAL_DOWNLOADER_ACTION_BUTTON = new BooleanSetting("revanced_external_downloader_action_button", FALSE);
public static final StringSetting EXTERNAL_DOWNLOADER_PACKAGE_NAME = new StringSetting("revanced_external_downloader_name",
"org.schabi.newpipe" /* NewPipe */, parentsAny(EXTERNAL_DOWNLOADER, EXTERNAL_DOWNLOADER_ACTION_BUTTON));
// Comments
public static final BooleanSetting HIDE_COMMENTS_BY_MEMBERS_HEADER = new BooleanSetting("revanced_hide_comments_by_members_header", FALSE);
public static final BooleanSetting HIDE_COMMENTS_SECTION = new BooleanSetting("revanced_hide_comments_section", FALSE);
public static final BooleanSetting HIDE_COMMENTS_CREATE_A_SHORT_BUTTON = new BooleanSetting("revanced_hide_comments_create_a_short_button", TRUE);
public static final BooleanSetting HIDE_COMMENTS_PREVIEW_COMMENT = new BooleanSetting("revanced_hide_comments_preview_comment", FALSE);
public static final BooleanSetting HIDE_COMMENTS_THANKS_BUTTON = new BooleanSetting("revanced_hide_comments_thanks_button", TRUE);
public static final BooleanSetting HIDE_COMMENTS_TIMESTAMP_AND_EMOJI_BUTTONS = new BooleanSetting("revanced_hide_comments_timestamp_and_emoji_buttons", TRUE);
// Description
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", TRUE);
public static final BooleanSetting HIDE_INFO_CARDS_SECTION = new BooleanSetting("revanced_hide_info_cards_section", TRUE);
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", TRUE);
public static final BooleanSetting HIDE_TRANSCRIPT_SECTION = new BooleanSetting("revanced_hide_transcript_section", TRUE);
// Action buttons
public static final BooleanSetting HIDE_LIKE_DISLIKE_BUTTON = new BooleanSetting("revanced_hide_like_dislike_button", FALSE);
public static final BooleanSetting HIDE_SHARE_BUTTON = new BooleanSetting("revanced_hide_share_button", FALSE);
public static final BooleanSetting HIDE_REPORT_BUTTON = new BooleanSetting("revanced_hide_report_button", FALSE);
public static final BooleanSetting HIDE_REMIX_BUTTON = new BooleanSetting("revanced_hide_remix_button", TRUE);
public static final BooleanSetting HIDE_DOWNLOAD_BUTTON = new BooleanSetting("revanced_hide_download_button", FALSE);
public static final BooleanSetting HIDE_THANKS_BUTTON = new BooleanSetting("revanced_hide_thanks_button", TRUE);
public static final BooleanSetting HIDE_CLIP_BUTTON = new BooleanSetting("revanced_hide_clip_button", TRUE);
public static final BooleanSetting HIDE_PLAYLIST_BUTTON = new BooleanSetting("revanced_hide_playlist_button", FALSE);
// Player flyout menu items
public static final BooleanSetting HIDE_PLAYER_FLYOUT_CAPTIONS = new BooleanSetting("revanced_hide_player_flyout_captions", FALSE);
public static final BooleanSetting HIDE_PLAYER_FLYOUT_ADDITIONAL_SETTINGS = new BooleanSetting("revanced_hide_player_flyout_additional_settings", FALSE);
public static final BooleanSetting HIDE_PLAYER_FLYOUT_LOOP_VIDEO = new BooleanSetting("revanced_hide_player_flyout_loop_video", FALSE);
public static final BooleanSetting HIDE_PLAYER_FLYOUT_AMBIENT_MODE = new BooleanSetting("revanced_hide_player_flyout_ambient_mode", FALSE);
public static final BooleanSetting HIDE_PLAYER_FLYOUT_HELP = new BooleanSetting("revanced_hide_player_flyout_help", TRUE);
public static final BooleanSetting HIDE_PLAYER_FLYOUT_SPEED = new BooleanSetting("revanced_hide_player_flyout_speed", FALSE);
public static final BooleanSetting HIDE_PLAYER_FLYOUT_MORE_INFO = new BooleanSetting("revanced_hide_player_flyout_more_info", TRUE);
public static final BooleanSetting HIDE_PLAYER_FLYOUT_LOCK_SCREEN = new BooleanSetting("revanced_hide_player_flyout_lock_screen", FALSE);
public static final BooleanSetting HIDE_PLAYER_FLYOUT_AUDIO_TRACK = new BooleanSetting("revanced_hide_player_flyout_audio_track", FALSE);
public static final BooleanSetting HIDE_PLAYER_FLYOUT_SLEEP_TIMER = new BooleanSetting("revanced_hide_player_flyout_sleep_timer", FALSE);
public static final BooleanSetting HIDE_PLAYER_FLYOUT_STABLE_VOLUME = new BooleanSetting("revanced_hide_player_flyout_stable_volume", FALSE);
public static final BooleanSetting HIDE_PLAYER_FLYOUT_WATCH_IN_VR = new BooleanSetting("revanced_hide_player_flyout_watch_in_vr", TRUE);
private static final BooleanSetting DEPRECATED_HIDE_PLAYER_FLYOUT_VIDEO_QUALITY_FOOTER = new BooleanSetting("revanced_hide_video_quality_menu_footer", FALSE);
public static final BooleanSetting HIDE_PLAYER_FLYOUT_VIDEO_QUALITY_FOOTER = new BooleanSetting("revanced_hide_player_flyout_video_quality_footer", FALSE);
// General layout
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", IS_19_17_OR_GREATER ? "19.35.36" : "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");
public static final BooleanSetting WIDE_SEARCHBAR = new BooleanSetting("revanced_wide_searchbar", FALSE, true);
public static final BooleanSetting BYPASS_IMAGE_REGION_RESTRICTIONS = new BooleanSetting("revanced_bypass_image_region_restrictions", FALSE, true);
public static final BooleanSetting REMOVE_VIEWER_DISCRETION_DIALOG = new BooleanSetting("revanced_remove_viewer_discretion_dialog", FALSE,
"revanced_remove_viewer_discretion_dialog_user_dialog_message");
// 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));
// Navigation buttons
public static final BooleanSetting HIDE_HOME_BUTTON = new BooleanSetting("revanced_hide_home_button", FALSE, true);
public static final BooleanSetting HIDE_CREATE_BUTTON = new BooleanSetting("revanced_hide_create_button", TRUE, true);
public static final BooleanSetting HIDE_SHORTS_BUTTON = new BooleanSetting("revanced_hide_shorts_button", TRUE, true);
public static final BooleanSetting HIDE_SUBSCRIPTIONS_BUTTON = new BooleanSetting("revanced_hide_subscriptions_button", FALSE, true);
public static final BooleanSetting HIDE_NAVIGATION_BUTTON_LABELS = new BooleanSetting("revanced_hide_navigation_button_labels", FALSE, true);
public static final BooleanSetting SWITCH_CREATE_WITH_NOTIFICATIONS_BUTTON = new BooleanSetting("revanced_switch_create_with_notifications_button", TRUE, true);
// Shorts
public static final BooleanSetting DISABLE_SHORTS_BACKGROUND_PLAYBACK = new BooleanSetting("revanced_shorts_disable_background_playback", FALSE);
public static final BooleanSetting DISABLE_RESUMING_SHORTS_PLAYER = new BooleanSetting("revanced_disable_resuming_shorts_player", FALSE);
public static final BooleanSetting HIDE_SHORTS_HOME = new BooleanSetting("revanced_hide_shorts_home", FALSE);
public static final BooleanSetting HIDE_SHORTS_SUBSCRIPTIONS = new BooleanSetting("revanced_hide_shorts_subscriptions", FALSE);
public static final BooleanSetting HIDE_SHORTS_SEARCH = new BooleanSetting("revanced_hide_shorts_search", FALSE);
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_OVERLAY_BUTTONS = new BooleanSetting("revanced_hide_shorts_paused_overlay_buttons", FALSE);
public static final BooleanSetting HIDE_SHORTS_SHOP_BUTTON = new BooleanSetting("revanced_hide_shorts_shop_button", TRUE);
public static final BooleanSetting HIDE_SHORTS_TAGGED_PRODUCTS = new BooleanSetting("revanced_hide_shorts_tagged_products", TRUE);
public static final BooleanSetting HIDE_SHORTS_LOCATION_LABEL = new BooleanSetting("revanced_hide_shorts_location_label", FALSE);
public static final BooleanSetting HIDE_SHORTS_SAVE_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_save_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_UPCOMING_BUTTON = new BooleanSetting("revanced_hide_shorts_upcoming_button", TRUE);
public static final BooleanSetting HIDE_SHORTS_GREEN_SCREEN_BUTTON = new BooleanSetting("revanced_hide_shorts_green_screen_button", TRUE);
public static final BooleanSetting HIDE_SHORTS_HASHTAG_BUTTON = new BooleanSetting("revanced_hide_shorts_hashtag_button", TRUE);
public static final BooleanSetting HIDE_SHORTS_SEARCH_SUGGESTIONS = new BooleanSetting("revanced_hide_shorts_search_suggestions", TRUE);
public static final BooleanSetting HIDE_SHORTS_STICKERS = new BooleanSetting("revanced_hide_shorts_stickers", 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_LIKE_FOUNTAIN = new BooleanSetting("revanced_hide_shorts_like_fountain", TRUE);
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_INFO_PANEL = new BooleanSetting("revanced_hide_shorts_info_panel", TRUE);
public static final BooleanSetting HIDE_SHORTS_SOUND_BUTTON = new BooleanSetting("revanced_hide_shorts_sound_button", 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", 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", FALSE, true);
public static final BooleanSetting SHORTS_AUTOPLAY = new BooleanSetting("revanced_shorts_autoplay", FALSE);
public static final BooleanSetting SHORTS_AUTOPLAY_BACKGROUND = new BooleanSetting("revanced_shorts_autoplay_background", 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, true);
public static final BooleanSetting RESTORE_OLD_SEEKBAR_THUMBNAILS = new BooleanSetting("revanced_restore_old_seekbar_thumbnails", TRUE);
public static final BooleanSetting SEEKBAR_THUMBNAILS_HIGH_QUALITY = new BooleanSetting("revanced_seekbar_thumbnails_high_quality", FALSE, true,
"revanced_seekbar_thumbnails_high_quality_dialog_message", new SeekbarThumbnailsHighQualityAvailability());
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 SEEKBAR_CUSTOM_COLOR = new BooleanSetting("revanced_seekbar_custom_color", FALSE, true);
public static final StringSetting SEEKBAR_CUSTOM_COLOR_VALUE = new StringSetting("revanced_seekbar_custom_color_value", "#FF0033", true, parent(SEEKBAR_CUSTOM_COLOR));
// Misc
public static final BooleanSetting AUTO_CAPTIONS = new BooleanSetting("revanced_auto_captions", FALSE);
public static final BooleanSetting DISABLE_ZOOM_HAPTICS = new BooleanSetting("revanced_disable_zoom_haptics", TRUE);
public static final BooleanSetting EXTERNAL_BROWSER = new BooleanSetting("revanced_external_browser", TRUE, true);
public static final BooleanSetting AUTO_REPEAT = new BooleanSetting("revanced_auto_repeat", FALSE);
public static final BooleanSetting SPOOF_DEVICE_DIMENSIONS = new BooleanSetting("revanced_spoof_device_dimensions", FALSE, true,
"revanced_spoof_device_dimensions_user_dialog_message");
public static final BooleanSetting BYPASS_URL_REDIRECTS = new BooleanSetting("revanced_bypass_url_redirects", TRUE);
public static final BooleanSetting ANNOUNCEMENTS = new BooleanSetting("revanced_announcements", TRUE);
public static final BooleanSetting SPOOF_VIDEO_STREAMS = new BooleanSetting("revanced_spoof_video_streams", TRUE, true,"revanced_spoof_video_streams_user_dialog_message");
public static final BooleanSetting SPOOF_VIDEO_STREAMS_IOS_FORCE_AVC = new BooleanSetting("revanced_spoof_video_streams_ios_force_avc", FALSE, true,
"revanced_spoof_video_streams_ios_force_avc_user_dialog_message", new SpoofVideoStreamsPatch.ForceiOSAVCAvailability());
public static final EnumSetting<ClientType> SPOOF_VIDEO_STREAMS_CLIENT_TYPE = new EnumSetting<>("revanced_spoof_video_streams_client", ClientType.ANDROID_VR, true, parent(SPOOF_VIDEO_STREAMS));
public static final IntegerSetting ANNOUNCEMENT_LAST_ID = new IntegerSetting("revanced_announcement_last_id", -1, false, false);
public static final BooleanSetting CHECK_WATCH_HISTORY_DOMAIN_NAME = new BooleanSetting("revanced_check_watch_history_domain_name", TRUE, false, false);
public static final BooleanSetting REMOVE_TRACKING_QUERY_PARAMETER = new BooleanSetting("revanced_remove_tracking_query_parameter", TRUE);
// Debugging
/**
* 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 DEBUG_PROTOBUFFER = new BooleanSetting("revanced_debug_protobuffer", FALSE, parent(BaseSettings.DEBUG));
// Swipe controls
public static final BooleanSetting SWIPE_BRIGHTNESS = new BooleanSetting("revanced_swipe_brightness", TRUE);
public static final BooleanSetting SWIPE_VOLUME = new BooleanSetting("revanced_swipe_volume", TRUE);
public static final BooleanSetting SWIPE_PRESS_TO_ENGAGE = new BooleanSetting("revanced_swipe_press_to_engage", FALSE, true,
parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
public static final BooleanSetting SWIPE_HAPTIC_FEEDBACK = new BooleanSetting("revanced_swipe_haptic_feedback", TRUE, true,
parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
public static final IntegerSetting SWIPE_MAGNITUDE_THRESHOLD = new IntegerSetting("revanced_swipe_threshold", 30, true,
parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
public static final IntegerSetting SWIPE_OVERLAY_BACKGROUND_ALPHA = new IntegerSetting("revanced_swipe_overlay_background_alpha", 127, true,
parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
public static final IntegerSetting SWIPE_OVERLAY_TEXT_SIZE = new IntegerSetting("revanced_swipe_text_overlay_size", 22, true,
parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
public static final LongSetting SWIPE_OVERLAY_TIMEOUT = new LongSetting("revanced_swipe_overlay_timeout", 500L, true,
parentsAny(SWIPE_BRIGHTNESS, SWIPE_VOLUME));
public static final BooleanSetting SWIPE_SAVE_AND_RESTORE_BRIGHTNESS = new BooleanSetting("revanced_swipe_save_and_restore_brightness", TRUE, true, parent(SWIPE_BRIGHTNESS));
public static final FloatSetting SWIPE_BRIGHTNESS_VALUE = new FloatSetting("revanced_swipe_brightness_value", -1f);
public static final BooleanSetting SWIPE_LOWEST_VALUE_ENABLE_AUTO_BRIGHTNESS = new BooleanSetting("revanced_swipe_lowest_value_enable_auto_brightness", FALSE, true, parent(SWIPE_BRIGHTNESS));
// ReturnYoutubeDislike
public static final BooleanSetting RYD_ENABLED = new BooleanSetting("ryd_enabled", TRUE);
public static final StringSetting RYD_USER_ID = new StringSetting("ryd_user_id", "", false, false);
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_TOAST_ON_CONNECTION_ERROR = new BooleanSetting("ryd_toast_on_connection_error", TRUE, parent(RYD_ENABLED));
// 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", "");
public static final StringSetting DEPRECATED_SB_UUID_OLD_MIGRATION_SETTING = new StringSetting("uuid", ""); // Delete sometime in 2024
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", TRUE, 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", TRUE, parent(SB_ENABLED));
public static final StringSetting SB_API_URL = new StringSetting("sb_api_url","https://sponsor.ajay.app");
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 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);
public static final StringSetting SB_CATEGORY_SPONSOR = new StringSetting("sb_sponsor", SKIP_AUTOMATICALLY_ONCE.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", MANUAL_SKIP.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", MANUAL_SKIP.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", MANUAL_SKIP.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", MANUAL_SKIP.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", IGNORE.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", IGNORE.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");
static {
// region Migration
// Do _not_ delete this SB private user id migration property until sometime in early 2025.
// This is the only setting that cannot be reconfigured if lost,
// and more time should be given for users who rarely upgrade.
SharedPrefCategory sbPrefs = new SharedPrefCategory("sponsor-block");
// Remove the "sb_" prefix, as old settings are saved without it.
String key = DEPRECATED_SB_UUID_OLD_MIGRATION_SETTING.key.substring(3);
migrateFromOldPreferences(sbPrefs, DEPRECATED_SB_UUID_OLD_MIGRATION_SETTING, key);
migrateOldSettingToNew(DEPRECATED_SB_UUID_OLD_MIGRATION_SETTING, SB_PRIVATE_USER_ID);
migrateOldSettingToNew(DEPRECATED_HIDE_PLAYER_BUTTONS, HIDE_PLAYER_PREVIOUS_NEXT_BUTTONS);
migrateOldSettingToNew(DEPRECATED_HIDE_PLAYER_FLYOUT_VIDEO_QUALITY_FOOTER, HIDE_PLAYER_FLYOUT_VIDEO_QUALITY_FOOTER);
// Old spoof versions that no longer work reliably.
if (SPOOF_APP_VERSION_TARGET.get().compareTo(SPOOF_APP_VERSION_TARGET.defaultValue) < 0) {
Logger.printInfo(() -> "Resetting spoof app version target");
SPOOF_APP_VERSION_TARGET.resetToDefault();
}
// Migrate renamed enum.
if (MINIPLAYER_TYPE.get() == PHONE) {
MINIPLAYER_TYPE.save(MINIMAL);
}
// endregion
// region SB import/export callbacks
Setting.addImportExportCallback(SponsorBlockSettings.SB_IMPORT_EXPORT_CALLBACK);
// endregion
}
}

View File

@ -0,0 +1,35 @@
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);
}
}

View File

@ -0,0 +1,61 @@
package app.revanced.extension.youtube.settings.preference;
import static app.revanced.extension.shared.StringRef.str;
import static app.revanced.extension.youtube.patches.spoof.DeviceHardwareSupport.DEVICE_HAS_HARDWARE_DECODING_VP9;
import android.content.Context;
import android.preference.SwitchPreference;
import android.util.AttributeSet;
@SuppressWarnings({"unused", "deprecation"})
public class ForceAVCSpoofingPreference extends SwitchPreference {
{
if (!DEVICE_HAS_HARDWARE_DECODING_VP9) {
setSummaryOn(str("revanced_spoof_video_streams_ios_force_avc_no_hardware_vp9_summary_on"));
}
}
public ForceAVCSpoofingPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public ForceAVCSpoofingPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public ForceAVCSpoofingPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ForceAVCSpoofingPreference(Context context) {
super(context);
}
private void updateUI() {
if (DEVICE_HAS_HARDWARE_DECODING_VP9) {
return;
}
// Temporarily remove the preference key to allow changing this preference without
// causing the settings UI listeners from showing reboot dialogs by the changes made here.
String key = getKey();
setKey(null);
// This setting cannot be changed by the user.
super.setEnabled(false);
super.setChecked(true);
setKey(key);
}
@Override
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
updateUI();
}
@Override
public void setChecked(boolean checked) {
super.setChecked(checked);
updateUI();
}
}

View File

@ -0,0 +1,35 @@
package app.revanced.extension.youtube.settings.preference;
import static android.text.Html.FROM_HTML_MODE_COMPACT;
import android.content.Context;
import android.os.Build;
import android.preference.Preference;
import android.text.Html;
import android.util.AttributeSet;
import androidx.annotation.RequiresApi;
/**
* Allows using basic html for the summary text.
*/
@SuppressWarnings({"unused", "deprecation"})
@RequiresApi(api = Build.VERSION_CODES.O)
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);
}
}

View File

@ -0,0 +1,113 @@
package app.revanced.extension.youtube.settings.preference;
import static app.revanced.extension.shared.Utils.getResourceIdentifier;
import android.annotation.SuppressLint;
import android.app.Dialog;
import android.graphics.Insets;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.preference.ListPreference;
import android.preference.Preference;
import android.preference.PreferenceScreen;
import android.util.TypedValue;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.widget.TextView;
import android.widget.Toolbar;
import androidx.annotation.RequiresApi;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.preference.AbstractPreferenceFragment;
import app.revanced.extension.youtube.ThemeHelper;
import app.revanced.extension.youtube.patches.playback.speed.CustomPlaybackSpeedPatch;
import app.revanced.extension.youtube.settings.Settings;
/**
* Preference fragment for ReVanced settings.
*
* @noinspection deprecation
*/
public class ReVancedPreferenceFragment extends AbstractPreferenceFragment {
@SuppressLint("UseCompatLoadingForDrawables")
public static Drawable getBackButtonDrawable() {
final int backButtonResource = getResourceIdentifier(ThemeHelper.isDarkTheme()
? "yt_outline_arrow_left_white_24"
: "yt_outline_arrow_left_black_24",
"drawable");
return Utils.getContext().getResources().getDrawable(backButtonResource);
}
@RequiresApi(api = Build.VERSION_CODES.O)
@Override
protected void initialize() {
super.initialize();
try {
setPreferenceScreenToolbar(getPreferenceScreen());
// If the preference was included, then initialize it based on the available playback speed.
Preference defaultSpeedPreference = findPreference(Settings.PLAYBACK_SPEED_DEFAULT.key);
if (defaultSpeedPreference instanceof ListPreference) {
CustomPlaybackSpeedPatch.initializeListPreference((ListPreference) defaultSpeedPreference);
}
} catch (Exception ex) {
Logger.printException(() -> "initialize failure", ex);
}
}
private void setPreferenceScreenToolbar(PreferenceScreen parentScreen) {
for (int i = 0, preferenceCount = parentScreen.getPreferenceCount(); i < preferenceCount; i++) {
Preference childPreference = parentScreen.getPreference(i);
if (childPreference instanceof PreferenceScreen) {
// Recursively set sub preferences.
setPreferenceScreenToolbar((PreferenceScreen) childPreference);
childPreference.setOnPreferenceClickListener(
childScreen -> {
Dialog preferenceScreenDialog = ((PreferenceScreen) childScreen).getDialog();
ViewGroup rootView = (ViewGroup) preferenceScreenDialog
.findViewById(android.R.id.content)
.getParent();
// Fix required for Android 15 and YT 19.45+
// FIXME:
// On Android 15 the text layout is not aligned the same as the parent
// screen and it looks a little off. Otherwise this works.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
rootView.setOnApplyWindowInsetsListener((v, insets) -> {
Insets statusInsets = insets.getInsets(WindowInsets.Type.statusBars());
v.setPadding(0, statusInsets.top, 0, 0);
return insets;
});
}
Toolbar toolbar = new Toolbar(childScreen.getContext());
toolbar.setTitle(childScreen.getTitle());
toolbar.setNavigationIcon(getBackButtonDrawable());
toolbar.setNavigationOnClickListener(view -> preferenceScreenDialog.dismiss());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
final int margin = (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 16, getResources().getDisplayMetrics()
);
toolbar.setTitleMargin(margin, 0, margin, 0);
}
TextView toolbarTextView = Utils.getChildView(toolbar,
true, TextView.class::isInstance);
if (toolbarTextView != null) {
toolbarTextView.setTextColor(ThemeHelper.getForegroundColor());
}
rootView.addView(toolbar, 0);
return false;
}
);
}
}
}
}

View File

@ -0,0 +1,32 @@
package app.revanced.extension.youtube.settings.preference;
import android.content.Context;
import android.util.AttributeSet;
import app.revanced.extension.shared.settings.preference.ReVancedAboutPreference;
import app.revanced.extension.youtube.ThemeHelper;
@SuppressWarnings("unused")
public class ReVancedYouTubeAboutPreference extends ReVancedAboutPreference {
public int getLightColor() {
return ThemeHelper.getLightThemeColor();
}
public int getDarkColor() {
return ThemeHelper.getDarkThemeColor();
}
public ReVancedYouTubeAboutPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
}
public ReVancedYouTubeAboutPreference(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
public ReVancedYouTubeAboutPreference(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ReVancedYouTubeAboutPreference(Context context) {
super(context);
}
}

View File

@ -0,0 +1,237 @@
package app.revanced.extension.youtube.settings.preference;
import static app.revanced.extension.shared.StringRef.str;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.preference.Preference;
import android.preference.PreferenceCategory;
import android.preference.PreferenceFragment;
import android.preference.PreferenceManager;
import android.preference.PreferenceScreen;
import android.preference.SwitchPreference;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.settings.Setting;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.youtube.patches.ReturnYouTubeDislikePatch;
import app.revanced.extension.youtube.returnyoutubedislike.ReturnYouTubeDislike;
import app.revanced.extension.youtube.returnyoutubedislike.requests.ReturnYouTubeDislikeApi;
import app.revanced.extension.youtube.settings.Settings;
/** @noinspection deprecation*/
public class ReturnYouTubeDislikePreferenceFragment extends PreferenceFragment {
/**
* If dislikes are shown on Shorts.
*/
private SwitchPreference shortsPreference;
/**
* If dislikes are shown as percentage.
*/
private SwitchPreference percentagePreference;
/**
* If segmented like/dislike button uses smaller compact layout.
*/
private SwitchPreference compactLayoutPreference;
/**
* If segmented like/dislike button uses smaller compact layout.
*/
private SwitchPreference toastOnRYDNotAvailable;
private void updateUIState() {
shortsPreference.setEnabled(Settings.RYD_SHORTS.isAvailable());
percentagePreference.setEnabled(Settings.RYD_DISLIKE_PERCENTAGE.isAvailable());
compactLayoutPreference.setEnabled(Settings.RYD_COMPACT_LAYOUT.isAvailable());
toastOnRYDNotAvailable.setEnabled(Settings.RYD_TOAST_ON_CONNECTION_ERROR.isAvailable());
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
try {
Activity context = getActivity();
PreferenceManager manager = getPreferenceManager();
manager.setSharedPreferencesName(Setting.preferences.name);
PreferenceScreen preferenceScreen = manager.createPreferenceScreen(context);
setPreferenceScreen(preferenceScreen);
SwitchPreference enabledPreference = new SwitchPreference(context);
enabledPreference.setChecked(Settings.RYD_ENABLED.get());
enabledPreference.setTitle(str("revanced_ryd_enable_title"));
enabledPreference.setSummaryOn(str("revanced_ryd_enable_summary_on"));
enabledPreference.setSummaryOff(str("revanced_ryd_enable_summary_off"));
enabledPreference.setOnPreferenceChangeListener((pref, newValue) -> {
final Boolean rydIsEnabled = (Boolean) newValue;
Settings.RYD_ENABLED.save(rydIsEnabled);
ReturnYouTubeDislikePatch.onRYDStatusChange(rydIsEnabled);
updateUIState();
return true;
});
preferenceScreen.addPreference(enabledPreference);
shortsPreference = new SwitchPreference(context);
shortsPreference.setChecked(Settings.RYD_SHORTS.get());
shortsPreference.setTitle(str("revanced_ryd_shorts_title"));
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);
shortsPreference.setSummaryOff(str("revanced_ryd_shorts_summary_off"));
shortsPreference.setOnPreferenceChangeListener((pref, newValue) -> {
Settings.RYD_SHORTS.save((Boolean) newValue);
updateUIState();
return true;
});
preferenceScreen.addPreference(shortsPreference);
percentagePreference = new SwitchPreference(context);
percentagePreference.setChecked(Settings.RYD_DISLIKE_PERCENTAGE.get());
percentagePreference.setTitle(str("revanced_ryd_dislike_percentage_title"));
percentagePreference.setSummaryOn(str("revanced_ryd_dislike_percentage_summary_on"));
percentagePreference.setSummaryOff(str("revanced_ryd_dislike_percentage_summary_off"));
percentagePreference.setOnPreferenceChangeListener((pref, newValue) -> {
Settings.RYD_DISLIKE_PERCENTAGE.save((Boolean) newValue);
ReturnYouTubeDislike.clearAllUICaches();
updateUIState();
return true;
});
preferenceScreen.addPreference(percentagePreference);
compactLayoutPreference = new SwitchPreference(context);
compactLayoutPreference.setChecked(Settings.RYD_COMPACT_LAYOUT.get());
compactLayoutPreference.setTitle(str("revanced_ryd_compact_layout_title"));
compactLayoutPreference.setSummaryOn(str("revanced_ryd_compact_layout_summary_on"));
compactLayoutPreference.setSummaryOff(str("revanced_ryd_compact_layout_summary_off"));
compactLayoutPreference.setOnPreferenceChangeListener((pref, newValue) -> {
Settings.RYD_COMPACT_LAYOUT.save((Boolean) newValue);
ReturnYouTubeDislike.clearAllUICaches();
updateUIState();
return true;
});
preferenceScreen.addPreference(compactLayoutPreference);
toastOnRYDNotAvailable = new SwitchPreference(context);
toastOnRYDNotAvailable.setChecked(Settings.RYD_TOAST_ON_CONNECTION_ERROR.get());
toastOnRYDNotAvailable.setTitle(str("revanced_ryd_toast_on_connection_error_title"));
toastOnRYDNotAvailable.setSummaryOn(str("revanced_ryd_toast_on_connection_error_summary_on"));
toastOnRYDNotAvailable.setSummaryOff(str("revanced_ryd_toast_on_connection_error_summary_off"));
toastOnRYDNotAvailable.setOnPreferenceChangeListener((pref, newValue) -> {
Settings.RYD_TOAST_ON_CONNECTION_ERROR.save((Boolean) newValue);
updateUIState();
return true;
});
preferenceScreen.addPreference(toastOnRYDNotAvailable);
updateUIState();
// About category
PreferenceCategory aboutCategory = new PreferenceCategory(context);
aboutCategory.setTitle(str("revanced_ryd_about"));
preferenceScreen.addPreference(aboutCategory);
// ReturnYouTubeDislike Website
Preference aboutWebsitePreference = new Preference(context);
aboutWebsitePreference.setTitle(str("revanced_ryd_attribution_title"));
aboutWebsitePreference.setSummary(str("revanced_ryd_attribution_summary"));
aboutWebsitePreference.setOnPreferenceClickListener(pref -> {
Intent i = new Intent(Intent.ACTION_VIEW);
i.setData(Uri.parse("https://returnyoutubedislike.com"));
pref.getContext().startActivity(i);
return false;
});
aboutCategory.addPreference(aboutWebsitePreference);
// RYD API connection statistics
if (BaseSettings.DEBUG.get()) {
PreferenceCategory emptyCategory = new PreferenceCategory(context); // vertical padding
preferenceScreen.addPreference(emptyCategory);
PreferenceCategory statisticsCategory = new PreferenceCategory(context);
statisticsCategory.setTitle(str("revanced_ryd_statistics_category_title"));
preferenceScreen.addPreference(statisticsCategory);
Preference statisticPreference;
statisticPreference = new Preference(context);
statisticPreference.setSelectable(false);
statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallResponseTimeAverage_title"));
statisticPreference.setSummary(createMillisecondStringFromNumber(ReturnYouTubeDislikeApi.getFetchCallResponseTimeAverage()));
preferenceScreen.addPreference(statisticPreference);
statisticPreference = new Preference(context);
statisticPreference.setSelectable(false);
statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallResponseTimeMin_title"));
statisticPreference.setSummary(createMillisecondStringFromNumber(ReturnYouTubeDislikeApi.getFetchCallResponseTimeMin()));
preferenceScreen.addPreference(statisticPreference);
statisticPreference = new Preference(context);
statisticPreference.setSelectable(false);
statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallResponseTimeMax_title"));
statisticPreference.setSummary(createMillisecondStringFromNumber(ReturnYouTubeDislikeApi.getFetchCallResponseTimeMax()));
preferenceScreen.addPreference(statisticPreference);
String fetchCallTimeWaitingLastSummary;
final long fetchCallTimeWaitingLast = ReturnYouTubeDislikeApi.getFetchCallResponseTimeLast();
if (fetchCallTimeWaitingLast == ReturnYouTubeDislikeApi.FETCH_CALL_RESPONSE_TIME_VALUE_RATE_LIMIT) {
fetchCallTimeWaitingLastSummary = str("revanced_ryd_statistics_getFetchCallResponseTimeLast_rate_limit_summary");
} else {
fetchCallTimeWaitingLastSummary = createMillisecondStringFromNumber(fetchCallTimeWaitingLast);
}
statisticPreference = new Preference(context);
statisticPreference.setSelectable(false);
statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallResponseTimeLast_title"));
statisticPreference.setSummary(fetchCallTimeWaitingLastSummary);
preferenceScreen.addPreference(statisticPreference);
statisticPreference = new Preference(context);
statisticPreference.setSelectable(false);
statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallCount_title"));
statisticPreference.setSummary(createSummaryText(ReturnYouTubeDislikeApi.getFetchCallCount(),
"revanced_ryd_statistics_getFetchCallCount_zero_summary",
"revanced_ryd_statistics_getFetchCallCount_non_zero_summary"));
preferenceScreen.addPreference(statisticPreference);
statisticPreference = new Preference(context);
statisticPreference.setSelectable(false);
statisticPreference.setTitle(str("revanced_ryd_statistics_getFetchCallNumberOfFailures_title"));
statisticPreference.setSummary(createSummaryText(ReturnYouTubeDislikeApi.getFetchCallNumberOfFailures(),
"revanced_ryd_statistics_getFetchCallNumberOfFailures_zero_summary",
"revanced_ryd_statistics_getFetchCallNumberOfFailures_non_zero_summary"));
preferenceScreen.addPreference(statisticPreference);
statisticPreference = new Preference(context);
statisticPreference.setSelectable(false);
statisticPreference.setTitle(str("revanced_ryd_statistics_getNumberOfRateLimitRequestsEncountered_title"));
statisticPreference.setSummary(createSummaryText(ReturnYouTubeDislikeApi.getNumberOfRateLimitRequestsEncountered(),
"revanced_ryd_statistics_getNumberOfRateLimitRequestsEncountered_zero_summary",
"revanced_ryd_statistics_getNumberOfRateLimitRequestsEncountered_non_zero_summary"));
preferenceScreen.addPreference(statisticPreference);
}
} catch (Exception ex) {
Logger.printException(() -> "onCreate failure", ex);
}
}
private static String createSummaryText(int value, String summaryStringZeroKey, String summaryStringOneOrMoreKey) {
if (value == 0) {
return str(summaryStringZeroKey);
}
return String.format(str(summaryStringOneOrMoreKey), value);
}
private static String createMillisecondStringFromNumber(long number) {
return String.format(str("revanced_ryd_statistics_millisecond_text"), number);
}
}

View File

@ -0,0 +1,602 @@
package app.revanced.extension.youtube.settings.preference;
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.os.Build;
import android.os.Bundle;
import android.preference.*;
import android.text.Html;
import android.text.InputType;
import android.util.TypedValue;
import android.widget.EditText;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import app.revanced.extension.shared.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.Setting;
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.SegmentCategoryListPreference;
import app.revanced.extension.youtube.sponsorblock.objects.UserStats;
import app.revanced.extension.youtube.sponsorblock.requests.SBRequester;
import app.revanced.extension.youtube.sponsorblock.ui.SponsorBlockViewController;
import static android.text.Html.fromHtml;
import static app.revanced.extension.shared.StringRef.str;
@SuppressWarnings("deprecation")
public class SponsorBlockPreferenceFragment extends PreferenceFragment {
private SwitchPreference sbEnabled;
private SwitchPreference addNewSegment;
private SwitchPreference votingEnabled;
private SwitchPreference compactSkipButton;
private SwitchPreference autoHideSkipSegmentButton;
private SwitchPreference showSkipToast;
private SwitchPreference trackSkips;
private SwitchPreference showTimeWithoutSegments;
private SwitchPreference toastOnConnectionError;
private EditTextPreference newSegmentStep;
private EditTextPreference minSegmentDuration;
private EditTextPreference privateUserId;
private EditTextPreference importExport;
private Preference apiUrl;
private PreferenceCategory statsCategory;
private PreferenceCategory segmentCategory;
private void updateUI() {
try {
final boolean enabled = Settings.SB_ENABLED.get();
if (!enabled) {
SponsorBlockViewController.hideAll();
SegmentPlaybackController.setCurrentVideoId(null);
} else if (!Settings.SB_CREATE_NEW_SEGMENT.get()) {
SponsorBlockViewController.hideNewSegmentLayout();
}
// Voting and add new segment buttons automatically shows/hide themselves.
sbEnabled.setChecked(enabled);
addNewSegment.setChecked(Settings.SB_CREATE_NEW_SEGMENT.get());
addNewSegment.setEnabled(enabled);
votingEnabled.setChecked(Settings.SB_VOTING_BUTTON.get());
votingEnabled.setEnabled(enabled);
compactSkipButton.setChecked(Settings.SB_COMPACT_SKIP_BUTTON.get());
compactSkipButton.setEnabled(enabled);
autoHideSkipSegmentButton.setChecked(Settings.SB_AUTO_HIDE_SKIP_BUTTON.get());
autoHideSkipSegmentButton.setEnabled(enabled);
showSkipToast.setChecked(Settings.SB_TOAST_ON_SKIP.get());
showSkipToast.setEnabled(enabled);
toastOnConnectionError.setChecked(Settings.SB_TOAST_ON_CONNECTION_ERROR.get());
toastOnConnectionError.setEnabled(enabled);
trackSkips.setChecked(Settings.SB_TRACK_SKIP_COUNT.get());
trackSkips.setEnabled(enabled);
showTimeWithoutSegments.setChecked(Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.get());
showTimeWithoutSegments.setEnabled(enabled);
newSegmentStep.setText((Settings.SB_CREATE_NEW_SEGMENT_STEP.get()).toString());
newSegmentStep.setEnabled(enabled);
minSegmentDuration.setText((Settings.SB_SEGMENT_MIN_DURATION.get()).toString());
minSegmentDuration.setEnabled(enabled);
privateUserId.setText(Settings.SB_PRIVATE_USER_ID.get());
privateUserId.setEnabled(enabled);
// 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");
importExport.setSummary(importExportSummary);
apiUrl.setEnabled(enabled);
importExport.setEnabled(enabled);
segmentCategory.setEnabled(enabled);
statsCategory.setEnabled(enabled);
} catch (Exception ex) {
Logger.printException(() -> "update settings UI failure", ex);
}
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
try {
Activity context = getActivity();
PreferenceManager manager = getPreferenceManager();
manager.setSharedPreferencesName(Setting.preferences.name);
PreferenceScreen preferenceScreen = manager.createPreferenceScreen(context);
setPreferenceScreen(preferenceScreen);
SponsorBlockSettings.initialize();
sbEnabled = new SwitchPreference(context);
sbEnabled.setTitle(str("revanced_sb_enable_sb"));
sbEnabled.setSummary(str("revanced_sb_enable_sb_sum"));
preferenceScreen.addPreference(sbEnabled);
sbEnabled.setOnPreferenceChangeListener((preference1, newValue) -> {
Settings.SB_ENABLED.save((Boolean) newValue);
updateUI();
return true;
});
addAppearanceCategory(context, preferenceScreen);
segmentCategory = new PreferenceCategory(context);
segmentCategory.setTitle(str("revanced_sb_diff_segments"));
preferenceScreen.addPreference(segmentCategory);
updateSegmentCategories();
addCreateSegmentCategory(context, preferenceScreen);
addGeneralCategory(context, preferenceScreen);
statsCategory = new PreferenceCategory(context);
statsCategory.setTitle(str("revanced_sb_stats"));
preferenceScreen.addPreference(statsCategory);
fetchAndDisplayStats();
addAboutCategory(context, preferenceScreen);
updateUI();
} catch (Exception ex) {
Logger.printException(() -> "onCreate failure", ex);
}
}
private void addAppearanceCategory(Context context, PreferenceScreen screen) {
PreferenceCategory category = new PreferenceCategory(context);
screen.addPreference(category);
category.setTitle(str("revanced_sb_appearance_category"));
votingEnabled = new SwitchPreference(context);
votingEnabled.setTitle(str("revanced_sb_enable_voting"));
votingEnabled.setSummaryOn(str("revanced_sb_enable_voting_sum_on"));
votingEnabled.setSummaryOff(str("revanced_sb_enable_voting_sum_off"));
category.addPreference(votingEnabled);
votingEnabled.setOnPreferenceChangeListener((preference1, newValue) -> {
Settings.SB_VOTING_BUTTON.save((Boolean) newValue);
updateUI();
return true;
});
compactSkipButton = new SwitchPreference(context);
compactSkipButton.setTitle(str("revanced_sb_enable_compact_skip_button"));
compactSkipButton.setSummaryOn(str("revanced_sb_enable_compact_skip_button_sum_on"));
compactSkipButton.setSummaryOff(str("revanced_sb_enable_compact_skip_button_sum_off"));
category.addPreference(compactSkipButton);
compactSkipButton.setOnPreferenceChangeListener((preference1, newValue) -> {
Settings.SB_COMPACT_SKIP_BUTTON.save((Boolean) newValue);
updateUI();
return true;
});
autoHideSkipSegmentButton = new SwitchPreference(context);
autoHideSkipSegmentButton.setTitle(str("revanced_sb_enable_auto_hide_skip_segment_button"));
autoHideSkipSegmentButton.setSummaryOn(str("revanced_sb_enable_auto_hide_skip_segment_button_sum_on"));
autoHideSkipSegmentButton.setSummaryOff(str("revanced_sb_enable_auto_hide_skip_segment_button_sum_off"));
category.addPreference(autoHideSkipSegmentButton);
autoHideSkipSegmentButton.setOnPreferenceChangeListener((preference1, newValue) -> {
Settings.SB_AUTO_HIDE_SKIP_BUTTON.save((Boolean) newValue);
updateUI();
return true;
});
showSkipToast = new SwitchPreference(context);
showSkipToast.setTitle(str("revanced_sb_general_skiptoast"));
showSkipToast.setSummaryOn(str("revanced_sb_general_skiptoast_sum_on"));
showSkipToast.setSummaryOff(str("revanced_sb_general_skiptoast_sum_off"));
showSkipToast.setOnPreferenceClickListener(preference1 -> {
Utils.showToastShort(str("revanced_sb_skipped_sponsor"));
return false;
});
showSkipToast.setOnPreferenceChangeListener((preference1, newValue) -> {
Settings.SB_TOAST_ON_SKIP.save((Boolean) newValue);
updateUI();
return true;
});
category.addPreference(showSkipToast);
showTimeWithoutSegments = new SwitchPreference(context);
showTimeWithoutSegments.setTitle(str("revanced_sb_general_time_without"));
showTimeWithoutSegments.setSummaryOn(str("revanced_sb_general_time_without_sum_on"));
showTimeWithoutSegments.setSummaryOff(str("revanced_sb_general_time_without_sum_off"));
showTimeWithoutSegments.setOnPreferenceChangeListener((preference1, newValue) -> {
Settings.SB_VIDEO_LENGTH_WITHOUT_SEGMENTS.save((Boolean) newValue);
updateUI();
return true;
});
category.addPreference(showTimeWithoutSegments);
}
private void addCreateSegmentCategory(Context context, PreferenceScreen screen) {
PreferenceCategory category = new PreferenceCategory(context);
screen.addPreference(category);
category.setTitle(str("revanced_sb_create_segment_category"));
addNewSegment = new SwitchPreference(context);
addNewSegment.setTitle(str("revanced_sb_enable_create_segment"));
addNewSegment.setSummaryOn(str("revanced_sb_enable_create_segment_sum_on"));
addNewSegment.setSummaryOff(str("revanced_sb_enable_create_segment_sum_off"));
category.addPreference(addNewSegment);
addNewSegment.setOnPreferenceChangeListener((preference1, o) -> {
Boolean newValue = (Boolean) o;
if (newValue && !Settings.SB_SEEN_GUIDELINES.get()) {
new AlertDialog.Builder(preference1.getContext())
.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())
.setOnDismissListener(dialog -> Settings.SB_SEEN_GUIDELINES.save(true))
.setCancelable(false)
.show();
}
Settings.SB_CREATE_NEW_SEGMENT.save(newValue);
updateUI();
return true;
});
newSegmentStep = new EditTextPreference(context);
newSegmentStep.setTitle(str("revanced_sb_general_adjusting"));
newSegmentStep.setSummary(str("revanced_sb_general_adjusting_sum"));
newSegmentStep.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER);
newSegmentStep.setOnPreferenceChangeListener((preference1, 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;
});
category.addPreference(newSegmentStep);
Preference guidelinePreferences = new Preference(context);
guidelinePreferences.setTitle(str("revanced_sb_guidelines_preference_title"));
guidelinePreferences.setSummary(str("revanced_sb_guidelines_preference_sum"));
guidelinePreferences.setOnPreferenceClickListener(preference1 -> {
openGuidelines();
return true;
});
category.addPreference(guidelinePreferences);
}
private void addGeneralCategory(final Context context, PreferenceScreen screen) {
PreferenceCategory category = new PreferenceCategory(context);
screen.addPreference(category);
category.setTitle(str("revanced_sb_general"));
toastOnConnectionError = new SwitchPreference(context);
toastOnConnectionError.setTitle(str("revanced_sb_toast_on_connection_error_title"));
toastOnConnectionError.setSummaryOn(str("revanced_sb_toast_on_connection_error_summary_on"));
toastOnConnectionError.setSummaryOff(str("revanced_sb_toast_on_connection_error_summary_off"));
toastOnConnectionError.setOnPreferenceChangeListener((preference1, newValue) -> {
Settings.SB_TOAST_ON_CONNECTION_ERROR.save((Boolean) newValue);
updateUI();
return true;
});
category.addPreference(toastOnConnectionError);
trackSkips = new SwitchPreference(context);
trackSkips.setTitle(str("revanced_sb_general_skipcount"));
trackSkips.setSummaryOn(str("revanced_sb_general_skipcount_sum_on"));
trackSkips.setSummaryOff(str("revanced_sb_general_skipcount_sum_off"));
trackSkips.setOnPreferenceChangeListener((preference1, newValue) -> {
Settings.SB_TRACK_SKIP_COUNT.save((Boolean) newValue);
updateUI();
return true;
});
category.addPreference(trackSkips);
minSegmentDuration = new EditTextPreference(context);
minSegmentDuration.setTitle(str("revanced_sb_general_min_duration"));
minSegmentDuration.setSummary(str("revanced_sb_general_min_duration_sum"));
minSegmentDuration.getEditText().setInputType(InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL);
minSegmentDuration.setOnPreferenceChangeListener((preference1, 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;
});
category.addPreference(minSegmentDuration);
privateUserId = new EditTextPreference(context);
privateUserId.setTitle(str("revanced_sb_general_uuid"));
privateUserId.setSummary(str("revanced_sb_general_uuid_sum"));
privateUserId.setOnPreferenceChangeListener((preference1, 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);
updateUI();
fetchAndDisplayStats();
return true;
});
category.addPreference(privateUserId);
apiUrl = new Preference(context);
apiUrl.setTitle(str("revanced_sb_general_api_url"));
apiUrl.setSummary(Html.fromHtml(str("revanced_sb_general_api_url_sum")));
apiUrl.setOnPreferenceClickListener(preference1 -> {
EditText editText = new EditText(context);
editText.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
editText.setText(Settings.SB_API_URL.get());
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"));
}
}
};
new AlertDialog.Builder(context)
.setTitle(apiUrl.getTitle())
.setView(editText)
.setNegativeButton(android.R.string.cancel, null)
.setNeutralButton(str("revanced_sb_reset"), urlChangeListener)
.setPositiveButton(android.R.string.ok, urlChangeListener)
.show();
return true;
});
category.addPreference(apiUrl);
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());
});
}
};
importExport.setTitle(str("revanced_sb_settings_ie"));
// Summary is set in updateUI()
importExport.getEditText().setInputType(InputType.TYPE_CLASS_TEXT
| InputType.TYPE_TEXT_FLAG_MULTI_LINE
| InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
importExport.getEditText().setAutofillHints((String) null);
}
importExport.getEditText().setTextSize(TypedValue.COMPLEX_UNIT_PT, 8);
importExport.setOnPreferenceClickListener(preference1 -> {
importExport.getEditText().setText(SponsorBlockSettings.exportDesktopSettings());
return true;
});
importExport.setOnPreferenceChangeListener((preference1, newValue) -> {
SponsorBlockSettings.importDesktopSettings((String) newValue);
updateSegmentCategories();
fetchAndDisplayStats();
updateUI();
return true;
});
category.addPreference(importExport);
}
private void updateSegmentCategories() {
try {
segmentCategory.removeAll();
Activity activity = getActivity();
for (SegmentCategory category : SegmentCategory.categoriesWithoutUnsubmitted()) {
segmentCategory.addPreference(new SegmentCategoryListPreference(activity, category));
}
} catch (Exception ex) {
Logger.printException(() -> "updateSegmentCategories failure", ex);
}
}
private void addAboutCategory(Context context, PreferenceScreen screen) {
PreferenceCategory category = new PreferenceCategory(context);
screen.addPreference(category);
category.setTitle(str("revanced_sb_about"));
{
Preference preference = new Preference(context);
category.addPreference(preference);
preference.setTitle(str("revanced_sb_about_api"));
preference.setSummary(str("revanced_sb_about_api_sum"));
preference.setOnPreferenceClickListener(preference1 -> {
Intent i = new Intent(Intent.ACTION_VIEW);
i.setData(Uri.parse("https://sponsor.ajay.app"));
preference1.getContext().startActivity(i);
return false;
});
}
}
private void openGuidelines() {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse("https://wiki.sponsor.ajay.app/w/Guidelines"));
getActivity().startActivity(intent);
}
private void fetchAndDisplayStats() {
try {
statsCategory.removeAll();
if (!SponsorBlockSettings.userHasSBPrivateId()) {
// User has never voted or created any segments. No stats to show.
addLocalUserStats();
return;
}
Preference loadingPlaceholderPreference = new Preference(this.getActivity());
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 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.
EditTextPreference preference = new EditTextPreference(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 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;
});
}
}

View File

@ -0,0 +1,309 @@
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.lang.ref.WeakReference;
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.Logger;
import app.revanced.extension.shared.Utils;
import app.revanced.extension.shared.settings.BaseSettings;
import app.revanced.extension.youtube.settings.Settings;
@SuppressWarnings("unused")
public final class NavigationBar {
//
// Search bar
//
private static volatile WeakReference<View> 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 PlayerType#isMaximizedOrFullscreen()}.
*/
public static boolean isSearchBarActive() {
View searchbarResults = searchBarResultsRef.get();
return searchbarResults != null && searchbarResults.getParent() != null;
}
//
// Navigation bar buttons
//
/**
* 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<View, NavigationButton> 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.DEBUG.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 (BaseSettings.DEBUG.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.
*
* <b>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.</b>
*
* @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<String> ytEnumNames;
NavigationButton(String... ytEnumNames) {
this.ytEnumNames = Arrays.asList(ytEnumNames);
}
}
}

View File

@ -0,0 +1,84 @@
package app.revanced.extension.youtube.shared
import android.app.Activity
import android.view.View
import android.view.ViewGroup
import app.revanced.extension.shared.Utils
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.youtube_controls_overlay
*/
private val controlsLayoutParentId =
Utils.getResourceIdentifier(activity, "youtube_controls_overlay", "id")
/**
* id of R.id.controls_layout
*/
private val controlsLayoutId =
Utils.getResourceIdentifier(activity, "controls_layout", "id")
/**
* reference to the controls layout view
*/
private var controlsLayoutView = WeakReference<View>(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<ViewGroup>(controlsLayoutParentId)?.let { parent ->
parent.findViewById<View>(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
}

Some files were not shown because too many files have changed in this diff Show More