mirror of
https://github.com/inotia00/revanced-patches.git
synced 2025-05-16 22:37:17 +02:00
feat(YouTube - Video playback): Improve Disable music playback speed
setting (#117)
* fix(Default Music playback speed): Fix side effect left * fix(Spoof Streaming Data): `approxDurationMs` can be fetched multiple time * fix(Spoof Streaming Data): Prevent playback issues on Kids videos with music * Up to now, there have been no recorded cases of playback issues in ANDROID_MUSIC. So it will be better to use this client instead of IOS to handle Kids video with music * Example video: https://youtu.be/bHtvEpeXrfc * fix: Apply code review suggestions * chore: Lint code --------- Co-authored-by: inotia00 <108592928+inotia00@users.noreply.github.com>
This commit is contained in:
parent
234695dd70
commit
1da2664513
@ -4,6 +4,9 @@ import android.os.Build
|
||||
import app.revanced.extension.shared.patches.PatchStatus
|
||||
import app.revanced.extension.shared.settings.BaseSettings
|
||||
|
||||
/**
|
||||
* Used to fetch streaming data.
|
||||
*/
|
||||
object AppClient {
|
||||
// IOS
|
||||
/**
|
||||
@ -46,7 +49,7 @@ object AppClient {
|
||||
|
||||
// IOS UNPLUGGED
|
||||
/**
|
||||
* Video not playable: Paid / Movie
|
||||
* Video not playable: Paid / Movie / Playlists / Music
|
||||
* Note: Audio track available
|
||||
*/
|
||||
private const val PACKAGE_NAME_IOS_UNPLUGGED = "com.google.ios.youtubeunplugged"
|
||||
@ -170,7 +173,6 @@ object AppClient {
|
||||
return BaseSettings.SPOOF_STREAMING_DATA_IOS_FORCE_AVC.get()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
val availableClientTypes: Array<ClientType>
|
||||
get() = if (PatchStatus.SpoofStreamingDataMusic())
|
||||
ClientType.CLIENT_ORDER_TO_USE_YOUTUBE_MUSIC
|
||||
@ -185,43 +187,35 @@ object AppClient {
|
||||
/**
|
||||
* Device model, equivalent to [Build.MODEL] (System property: ro.product.model)
|
||||
*/
|
||||
@JvmField
|
||||
val deviceModel: String? = Build.MODEL,
|
||||
val deviceModel: String = Build.MODEL,
|
||||
/**
|
||||
* Device OS version, equivalent to [Build.VERSION.RELEASE] (System property: ro.system.build.version.release)
|
||||
*/
|
||||
@JvmField
|
||||
val osVersion: String? = Build.VERSION.RELEASE,
|
||||
val osVersion: String = Build.VERSION.RELEASE,
|
||||
/**
|
||||
* Client user-agent.
|
||||
*/
|
||||
@JvmField
|
||||
val userAgent: String,
|
||||
/**
|
||||
* Android SDK version, equivalent to [Build.VERSION.SDK] (System property: ro.build.version.sdk)
|
||||
* Field is null if not applicable.
|
||||
*/
|
||||
@JvmField
|
||||
val androidSdkVersion: String? = null,
|
||||
/**
|
||||
* App version.
|
||||
*/
|
||||
@JvmField
|
||||
val clientVersion: String,
|
||||
/**
|
||||
* If the client can access the API logged in.
|
||||
*/
|
||||
@JvmField
|
||||
val canLogin: Boolean? = true,
|
||||
val canLogin: Boolean = true,
|
||||
/**
|
||||
* If a poToken should be used.
|
||||
* Whether a poToken is required to get playback for more than 1 minute.
|
||||
*/
|
||||
@JvmField
|
||||
val usePoToken: Boolean? = false,
|
||||
val requirePoToken: Boolean = false,
|
||||
/**
|
||||
* Friendly name displayed in stats for nerds.
|
||||
*/
|
||||
@JvmField
|
||||
val friendlyName: String
|
||||
) {
|
||||
ANDROID_VR(
|
||||
@ -260,7 +254,7 @@ object AppClient {
|
||||
userAgent = USER_AGENT_IOS,
|
||||
clientVersion = CLIENT_VERSION_IOS,
|
||||
canLogin = false,
|
||||
usePoToken = true,
|
||||
requirePoToken = true,
|
||||
friendlyName = if (forceAVC())
|
||||
"iOS Force AVC"
|
||||
else
|
||||
@ -274,7 +268,6 @@ object AppClient {
|
||||
friendlyName = "Android Music"
|
||||
);
|
||||
|
||||
@JvmField
|
||||
val clientName: String = name
|
||||
|
||||
companion object {
|
||||
|
@ -0,0 +1,43 @@
|
||||
package app.revanced.extension.shared.patches.client
|
||||
|
||||
/**
|
||||
* Used to fetch video information.
|
||||
*/
|
||||
@Suppress("unused")
|
||||
object WebClient {
|
||||
/**
|
||||
* This user agent does not require a PoToken in [ClientType.MWEB]
|
||||
* https://github.com/yt-dlp/yt-dlp/blob/0b6b7742c2e7f2a1fcb0b54ef3dd484bab404b3f/yt_dlp/extractor/youtube.py#L259
|
||||
*/
|
||||
private const val USER_AGENT_SAFARI =
|
||||
"Mozilla/5.0 (iPad; CPU OS 16_7_10 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1,gzip(gfe)"
|
||||
|
||||
enum class ClientType(
|
||||
/**
|
||||
* [YouTube client type](https://github.com/zerodytrash/YouTube-Internal-Clients?tab=readme-ov-file#clients)
|
||||
*/
|
||||
val id: Int,
|
||||
/**
|
||||
* Client user-agent.
|
||||
*/
|
||||
@JvmField
|
||||
val userAgent: String = USER_AGENT_SAFARI,
|
||||
/**
|
||||
* Client version.
|
||||
*/
|
||||
@JvmField
|
||||
val clientVersion: String
|
||||
) {
|
||||
MWEB(
|
||||
id = 2,
|
||||
clientVersion = "2.20241202.07.00"
|
||||
),
|
||||
WEB_REMIX(
|
||||
id = 29,
|
||||
clientVersion = "1.20241127.01.00",
|
||||
);
|
||||
|
||||
@JvmField
|
||||
val clientName: String = name
|
||||
}
|
||||
}
|
@ -222,7 +222,6 @@ public class SpoofStreamingDataPatch {
|
||||
final Long approxDurationMs = approxDurationMsMap.get(videoId);
|
||||
if (approxDurationMs != null) {
|
||||
Logger.printDebug(() -> "Replacing video length: " + approxDurationMs + " for videoId: " + videoId);
|
||||
approxDurationMsMap.remove(videoId);
|
||||
return approxDurationMs;
|
||||
}
|
||||
}
|
||||
|
@ -1,90 +0,0 @@
|
||||
package app.revanced.extension.shared.patches.spoof.requests;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
|
||||
import app.revanced.extension.shared.patches.client.AppClient.ClientType;
|
||||
import app.revanced.extension.shared.requests.Requester;
|
||||
import app.revanced.extension.shared.requests.Route;
|
||||
import app.revanced.extension.shared.utils.Logger;
|
||||
import app.revanced.extension.shared.utils.Utils;
|
||||
|
||||
@SuppressWarnings({"ExtractMethodRecommender", "deprecation"})
|
||||
public final class PlayerRoutes {
|
||||
public static final Route.CompiledRoute GET_PLAYLIST_PAGE = new Route(
|
||||
Route.Method.POST,
|
||||
"next" +
|
||||
"?fields=contents.singleColumnWatchNextResults.playlist.playlist"
|
||||
).compile();
|
||||
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 static final String LOCALE_LANGUAGE = Utils.getContext().getResources()
|
||||
.getConfiguration().locale.getLanguage();
|
||||
|
||||
private PlayerRoutes() {
|
||||
}
|
||||
|
||||
public static JSONObject createInnertubeBody(ClientType clientType) {
|
||||
JSONObject innerTubeBody = new JSONObject();
|
||||
|
||||
try {
|
||||
JSONObject client = new JSONObject();
|
||||
client.put("clientName", clientType.clientName);
|
||||
client.put("clientVersion", clientType.clientVersion);
|
||||
client.put("deviceModel", clientType.deviceModel);
|
||||
client.put("osVersion", clientType.osVersion);
|
||||
if (clientType.androidSdkVersion != null) {
|
||||
client.put("androidSdkVersion", clientType.androidSdkVersion);
|
||||
client.put("osName", "Android");
|
||||
} else {
|
||||
client.put("deviceMake", "Apple");
|
||||
client.put("osName", "iOS");
|
||||
}
|
||||
if (!clientType.canLogin) {
|
||||
client.put("hl", LOCALE_LANGUAGE);
|
||||
}
|
||||
|
||||
JSONObject context = new JSONObject();
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @noinspection SameParameterValue
|
||||
*/
|
||||
public 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;
|
||||
}
|
||||
}
|
@ -0,0 +1,147 @@
|
||||
package app.revanced.extension.shared.patches.spoof.requests
|
||||
|
||||
import app.revanced.extension.shared.patches.client.AppClient
|
||||
import app.revanced.extension.shared.patches.client.WebClient
|
||||
import app.revanced.extension.shared.requests.Requester
|
||||
import app.revanced.extension.shared.requests.Route
|
||||
import app.revanced.extension.shared.requests.Route.CompiledRoute
|
||||
import app.revanced.extension.shared.utils.Logger
|
||||
import app.revanced.extension.shared.utils.Utils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@Suppress("deprecation")
|
||||
object PlayerRoutes {
|
||||
@JvmField
|
||||
val GET_CATEGORY: CompiledRoute = Route(
|
||||
Route.Method.POST,
|
||||
"player" +
|
||||
"?fields=microformat.playerMicroformatRenderer.category"
|
||||
).compile()
|
||||
|
||||
@JvmField
|
||||
val GET_PLAYLIST_PAGE: CompiledRoute = Route(
|
||||
Route.Method.POST,
|
||||
"next" +
|
||||
"?fields=contents.singleColumnWatchNextResults.playlist.playlist"
|
||||
).compile()
|
||||
|
||||
@JvmField
|
||||
val GET_STREAMING_DATA: CompiledRoute = Route(
|
||||
Route.Method.POST,
|
||||
"player" +
|
||||
"?fields=streamingData" +
|
||||
"&alt=proto"
|
||||
).compile()
|
||||
|
||||
private const val YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/"
|
||||
|
||||
/**
|
||||
* TCP connection and HTTP read timeout
|
||||
*/
|
||||
private const val CONNECTION_TIMEOUT_MILLISECONDS = 10 * 1000 // 10 Seconds.
|
||||
|
||||
private val LOCALE_LANGUAGE: String = Utils.getContext().resources
|
||||
.configuration.locale.language
|
||||
|
||||
@JvmStatic
|
||||
fun createApplicationRequestBody(
|
||||
clientType: AppClient.ClientType,
|
||||
videoId: String,
|
||||
playlistId: String? = null,
|
||||
botGuardPoToken: String? = null,
|
||||
visitorId: String? = null,
|
||||
): ByteArray {
|
||||
val innerTubeBody = JSONObject()
|
||||
|
||||
try {
|
||||
val client = JSONObject()
|
||||
client.put("clientName", clientType.clientName)
|
||||
client.put("clientVersion", clientType.clientVersion)
|
||||
client.put("deviceModel", clientType.deviceModel)
|
||||
client.put("osVersion", clientType.osVersion)
|
||||
if (clientType.androidSdkVersion != null) {
|
||||
client.put("androidSdkVersion", clientType.androidSdkVersion)
|
||||
client.put("osName", "Android")
|
||||
} else {
|
||||
client.put("deviceMake", "Apple")
|
||||
client.put("osName", "iOS")
|
||||
}
|
||||
if (!clientType.canLogin) {
|
||||
client.put("hl", LOCALE_LANGUAGE)
|
||||
}
|
||||
|
||||
val context = JSONObject()
|
||||
context.put("client", client)
|
||||
|
||||
innerTubeBody.put("context", context)
|
||||
innerTubeBody.put("contentCheckOk", true)
|
||||
innerTubeBody.put("racyCheckOk", true)
|
||||
innerTubeBody.put("videoId", videoId)
|
||||
|
||||
if (playlistId != null) {
|
||||
innerTubeBody.put("playlistId", playlistId)
|
||||
}
|
||||
|
||||
if (!StringUtils.isAnyEmpty(botGuardPoToken, visitorId)) {
|
||||
val serviceIntegrityDimensions = JSONObject()
|
||||
serviceIntegrityDimensions.put("poToken", botGuardPoToken)
|
||||
innerTubeBody.put("serviceIntegrityDimensions", serviceIntegrityDimensions)
|
||||
}
|
||||
} catch (e: JSONException) {
|
||||
Logger.printException({ "Failed to create application innerTubeBody" }, e)
|
||||
}
|
||||
|
||||
return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun createWebInnertubeBody(
|
||||
clientType: WebClient.ClientType,
|
||||
videoId: String
|
||||
): ByteArray {
|
||||
val innerTubeBody = JSONObject()
|
||||
|
||||
try {
|
||||
val client = JSONObject()
|
||||
client.put("clientName", clientType.clientName)
|
||||
client.put("clientVersion", clientType.clientVersion)
|
||||
val context = JSONObject()
|
||||
context.put("client", client)
|
||||
|
||||
val lockedSafetyMode = JSONObject()
|
||||
lockedSafetyMode.put("lockedSafetyMode", false)
|
||||
val user = JSONObject()
|
||||
user.put("user", lockedSafetyMode)
|
||||
|
||||
innerTubeBody.put("context", context)
|
||||
innerTubeBody.put("contentCheckOk", true)
|
||||
innerTubeBody.put("racyCheckOk", true)
|
||||
innerTubeBody.put("videoId", videoId)
|
||||
} catch (e: JSONException) {
|
||||
Logger.printException({ "Failed to create web innerTubeBody" }, e)
|
||||
}
|
||||
|
||||
return innerTubeBody.toString().toByteArray(StandardCharsets.UTF_8)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Throws(IOException::class)
|
||||
fun getPlayerResponseConnectionFromRoute(route: CompiledRoute, userAgent: String): HttpURLConnection {
|
||||
val connection = Requester.getConnectionFromCompiledRoute(YT_API_URL, route)
|
||||
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.setRequestProperty("User-Agent", userAgent)
|
||||
|
||||
connection.useCaches = false
|
||||
connection.doOutput = true
|
||||
|
||||
connection.connectTimeout = CONNECTION_TIMEOUT_MILLISECONDS
|
||||
connection.readTimeout = CONNECTION_TIMEOUT_MILLISECONDS
|
||||
return connection
|
||||
}
|
||||
}
|
@ -1,267 +0,0 @@
|
||||
package app.revanced.extension.shared.patches.spoof.requests;
|
||||
|
||||
import static app.revanced.extension.shared.patches.client.AppClient.getAvailableClientTypes;
|
||||
import static app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes.GET_STREAMING_DATA;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
import org.json.JSONObject;
|
||||
|
||||
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.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import app.revanced.extension.shared.patches.client.AppClient.ClientType;
|
||||
import app.revanced.extension.shared.settings.BaseSettings;
|
||||
import app.revanced.extension.shared.utils.Logger;
|
||||
import app.revanced.extension.shared.utils.Utils;
|
||||
|
||||
/**
|
||||
* 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 VISITOR_ID_HEADER = "X-Goog-Visitor-Id";
|
||||
private static final String[] REQUEST_HEADER_KEYS = {
|
||||
AUTHORIZATION_HEADER, // Available only to logged-in users.
|
||||
"X-GOOG-API-FORMAT-VERSION",
|
||||
VISITOR_ID_HEADER
|
||||
};
|
||||
private static ClientType lastSpoofedClientType;
|
||||
|
||||
|
||||
/**
|
||||
* 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.
|
||||
}
|
||||
});
|
||||
|
||||
public static String getLastSpoofedClientName() {
|
||||
return lastSpoofedClientType == null
|
||||
? "Unknown"
|
||||
: lastSpoofedClientType.friendlyName;
|
||||
}
|
||||
|
||||
static {
|
||||
ClientType[] allClientTypes = getAvailableClientTypes();
|
||||
ClientType preferredClient = BaseSettings.SPOOF_STREAMING_DATA_TYPE.get();
|
||||
|
||||
if (ArrayUtils.indexOf(allClientTypes, preferredClient) < 0) {
|
||||
CLIENT_ORDER_TO_USE = allClientTypes;
|
||||
} else {
|
||||
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, String visitorId,
|
||||
String botGuardPoToken, String droidGuardPoToken) {
|
||||
Objects.requireNonNull(playerHeaders);
|
||||
this.videoId = videoId;
|
||||
this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId, playerHeaders, visitorId, botGuardPoToken, droidGuardPoToken));
|
||||
}
|
||||
|
||||
public static void fetchRequest(String videoId, Map<String, String> fetchHeaders, String visitorId,
|
||||
String botGuardPoToken, String droidGuardPoToken) {
|
||||
// Always fetch, even if there is an existing request for the same video.
|
||||
cache.put(videoId, new StreamingDataRequest(videoId, fetchHeaders, visitorId, botGuardPoToken, droidGuardPoToken));
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static StreamingDataRequest getRequestForVideoId(String videoId) {
|
||||
return cache.get(videoId);
|
||||
}
|
||||
|
||||
private static void handleConnectionError(String toastMessage, @Nullable Exception ex) {
|
||||
Logger.printInfo(() -> toastMessage, ex);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static HttpURLConnection send(ClientType clientType, String videoId, Map<String, String> playerHeaders,
|
||||
String visitorId, String botGuardPoToken, String droidGuardPoToken) {
|
||||
Objects.requireNonNull(clientType);
|
||||
Objects.requireNonNull(videoId);
|
||||
Objects.requireNonNull(playerHeaders);
|
||||
|
||||
final long startTime = System.currentTimeMillis();
|
||||
Logger.printDebug(() -> "Fetching video streams for: " + videoId + " using client: " + clientType);
|
||||
|
||||
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) {
|
||||
String value = playerHeaders.get(key);
|
||||
if (value != null) {
|
||||
if (key.equals(AUTHORIZATION_HEADER)) {
|
||||
if (!clientType.canLogin) {
|
||||
Logger.printDebug(() -> "Not including request header: " + key);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (key.equals(VISITOR_ID_HEADER) &&
|
||||
clientType.usePoToken &&
|
||||
!botGuardPoToken.isEmpty() &&
|
||||
!visitorId.isEmpty()) {
|
||||
String originalVisitorId = value;
|
||||
Logger.printDebug(() -> "Original visitor id:\n" + originalVisitorId);
|
||||
Logger.printDebug(() -> "Replaced visitor id:\n" + visitorId);
|
||||
value = visitorId;
|
||||
}
|
||||
|
||||
connection.setRequestProperty(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
JSONObject innerTubeBodyJson = PlayerRoutes.createInnertubeBody(clientType);
|
||||
if (clientType.usePoToken && !botGuardPoToken.isEmpty() && !visitorId.isEmpty()) {
|
||||
JSONObject serviceIntegrityDimensions = new JSONObject();
|
||||
serviceIntegrityDimensions.put("poToken", botGuardPoToken);
|
||||
innerTubeBodyJson.put("serviceIntegrityDimensions", serviceIntegrityDimensions);
|
||||
if (!droidGuardPoToken.isEmpty()) {
|
||||
Logger.printDebug(() -> "Original poToken (droidGuardPoToken):\n" + droidGuardPoToken);
|
||||
}
|
||||
Logger.printDebug(() -> "Replaced poToken (botGuardPoToken):\n" + botGuardPoToken);
|
||||
}
|
||||
|
||||
String innerTubeBody = String.format(innerTubeBodyJson.toString(), 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;
|
||||
|
||||
// This situation likely means the patches are outdated.
|
||||
// Use a toast message that suggests updating.
|
||||
handleConnectionError("Playback error (App is outdated?) " + clientType + ": "
|
||||
+ responseCode + " response: " + connection.getResponseMessage(),
|
||||
null);
|
||||
} catch (SocketTimeoutException ex) {
|
||||
handleConnectionError("Connection timeout", ex);
|
||||
} catch (IOException ex) {
|
||||
handleConnectionError("Network error", ex);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "send failed", ex);
|
||||
} finally {
|
||||
Logger.printDebug(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ByteBuffer fetch(String videoId, Map<String, String> playerHeaders, String visitorId,
|
||||
String botGuardPoToken, String droidGuardPoToken) {
|
||||
lastSpoofedClientType = null;
|
||||
|
||||
// Retry with different client if empty response body is received.
|
||||
for (ClientType clientType : CLIENT_ORDER_TO_USE) {
|
||||
HttpURLConnection connection = send(clientType, videoId, playerHeaders, visitorId, botGuardPoToken, droidGuardPoToken);
|
||||
if (connection != null) {
|
||||
try {
|
||||
// gzip encoding doesn't response with content length (-1),
|
||||
// but empty response body does.
|
||||
if (connection.getContentLength() == 0) {
|
||||
Logger.printDebug(() -> "Received empty response" + "\nClient: " + clientType + "\nVideo: " + videoId);
|
||||
} else {
|
||||
try (InputStream inputStream = new BufferedInputStream(connection.getInputStream());
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[2048];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) >= 0) {
|
||||
baos.write(buffer, 0, bytesRead);
|
||||
}
|
||||
lastSpoofedClientType = clientType;
|
||||
|
||||
return ByteBuffer.wrap(baos.toByteArray());
|
||||
}
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
Logger.printException(() -> "Fetch failed while processing response data", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectionError("Could not fetch any client streams", null);
|
||||
return null;
|
||||
}
|
||||
|
||||
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 + '\'' + '}';
|
||||
}
|
||||
}
|
@ -0,0 +1,299 @@
|
||||
package app.revanced.extension.shared.patches.spoof.requests
|
||||
|
||||
import androidx.annotation.GuardedBy
|
||||
import app.revanced.extension.shared.patches.client.AppClient
|
||||
import app.revanced.extension.shared.patches.client.AppClient.availableClientTypes
|
||||
import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes.GET_STREAMING_DATA
|
||||
import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes.createApplicationRequestBody
|
||||
import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes.getPlayerResponseConnectionFromRoute
|
||||
import app.revanced.extension.shared.settings.BaseSettings
|
||||
import app.revanced.extension.shared.utils.Logger
|
||||
import app.revanced.extension.shared.utils.Utils
|
||||
import org.apache.commons.lang3.ArrayUtils
|
||||
import org.apache.commons.lang3.StringUtils
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketTimeoutException
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.Collections
|
||||
import java.util.Objects
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Future
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
/**
|
||||
* Video streaming data. Fetching is tied to the behavior YT uses,
|
||||
* where this class fetches the streams only when YT fetches.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
class StreamingDataRequest private constructor(
|
||||
videoId: String, playerHeaders: Map<String, String>, visitorId: String,
|
||||
botGuardPoToken: String, droidGuardPoToken: String
|
||||
) {
|
||||
private val videoId: String
|
||||
private val future: Future<ByteBuffer?>
|
||||
|
||||
init {
|
||||
Objects.requireNonNull(playerHeaders)
|
||||
this.videoId = videoId
|
||||
this.future = Utils.submitOnBackgroundThread {
|
||||
fetch(
|
||||
videoId,
|
||||
playerHeaders,
|
||||
visitorId,
|
||||
botGuardPoToken,
|
||||
droidGuardPoToken
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchCompleted(): Boolean {
|
||||
return future.isDone
|
||||
}
|
||||
|
||||
val stream: ByteBuffer?
|
||||
get() {
|
||||
try {
|
||||
return future[MAX_MILLISECONDS_TO_WAIT_FOR_FETCH.toLong(), TimeUnit.MILLISECONDS]
|
||||
} catch (ex: TimeoutException) {
|
||||
Logger.printInfo(
|
||||
{ "getStream timed out" },
|
||||
ex
|
||||
)
|
||||
} catch (ex: InterruptedException) {
|
||||
Logger.printException(
|
||||
{ "getStream interrupted" },
|
||||
ex
|
||||
)
|
||||
Thread.currentThread().interrupt() // Restore interrupt status flag.
|
||||
} catch (ex: ExecutionException) {
|
||||
Logger.printException(
|
||||
{ "getStream failure" },
|
||||
ex
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "StreamingDataRequest{videoId='$videoId'}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var CLIENT_ORDER_TO_USE: Array<AppClient.ClientType>
|
||||
private const val AUTHORIZATION_HEADER = "Authorization"
|
||||
private const val VISITOR_ID_HEADER = "X-Goog-Visitor-Id"
|
||||
private val REQUEST_HEADER_KEYS = arrayOf(
|
||||
AUTHORIZATION_HEADER, // Available only to logged-in users.
|
||||
"X-GOOG-API-FORMAT-VERSION",
|
||||
VISITOR_ID_HEADER
|
||||
)
|
||||
private var lastSpoofedClientType: AppClient.ClientType? = null
|
||||
|
||||
|
||||
/**
|
||||
* TCP connection and HTTP read timeout.
|
||||
*/
|
||||
private const val HTTP_TIMEOUT_MILLISECONDS = 10 * 1000
|
||||
|
||||
/**
|
||||
* Any arbitrarily large value, but must be at least twice [.HTTP_TIMEOUT_MILLISECONDS]
|
||||
*/
|
||||
private const val MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000
|
||||
@GuardedBy("itself")
|
||||
val cache: MutableMap<String, StreamingDataRequest> = Collections.synchronizedMap(
|
||||
object : LinkedHashMap<String, StreamingDataRequest>(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 val CACHE_LIMIT = 50
|
||||
|
||||
override fun removeEldestEntry(eldest: Map.Entry<String, StreamingDataRequest>): Boolean {
|
||||
return size > CACHE_LIMIT // Evict the oldest entry if over the cache limit.
|
||||
}
|
||||
})
|
||||
|
||||
@JvmStatic
|
||||
val lastSpoofedClientName: String
|
||||
get() = lastSpoofedClientType
|
||||
?.friendlyName
|
||||
?: "Unknown"
|
||||
|
||||
init {
|
||||
val allClientTypes: Array<AppClient.ClientType> = availableClientTypes
|
||||
val preferredClient = BaseSettings.SPOOF_STREAMING_DATA_TYPE.get()
|
||||
|
||||
CLIENT_ORDER_TO_USE = allClientTypes
|
||||
if (ArrayUtils.indexOf(allClientTypes, preferredClient) >= 0) {
|
||||
CLIENT_ORDER_TO_USE[0] = preferredClient
|
||||
var i = 1
|
||||
for (c in allClientTypes) {
|
||||
if (c != preferredClient) {
|
||||
CLIENT_ORDER_TO_USE[i++] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun fetchRequest(
|
||||
videoId: String, fetchHeaders: Map<String, String>, visitorId: String,
|
||||
botGuardPoToken: String, droidGuardPoToken: String
|
||||
) {
|
||||
// Always fetch, even if there is an existing request for the same video.
|
||||
cache[videoId] =
|
||||
StreamingDataRequest(
|
||||
videoId,
|
||||
fetchHeaders,
|
||||
visitorId,
|
||||
botGuardPoToken,
|
||||
droidGuardPoToken
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getRequestForVideoId(videoId: String): StreamingDataRequest? {
|
||||
return cache[videoId]
|
||||
}
|
||||
|
||||
private fun handleConnectionError(toastMessage: String, ex: Exception?) {
|
||||
Logger.printInfo({ toastMessage }, ex)
|
||||
}
|
||||
|
||||
private fun send(
|
||||
clientType: AppClient.ClientType, videoId: String, playerHeaders: Map<String, String>,
|
||||
visitorId: String, botGuardPoToken: String, droidGuardPoToken: String
|
||||
): HttpURLConnection? {
|
||||
Objects.requireNonNull(clientType)
|
||||
Objects.requireNonNull(videoId)
|
||||
Objects.requireNonNull(playerHeaders)
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
Logger.printDebug { "Fetching video streams for: $videoId using client: $clientType" }
|
||||
|
||||
try {
|
||||
val connection = getPlayerResponseConnectionFromRoute(GET_STREAMING_DATA, clientType.userAgent)
|
||||
connection.connectTimeout = HTTP_TIMEOUT_MILLISECONDS
|
||||
connection.readTimeout = HTTP_TIMEOUT_MILLISECONDS
|
||||
|
||||
val usePoToken = clientType.requirePoToken && !StringUtils.isAnyEmpty(botGuardPoToken, visitorId)
|
||||
|
||||
for (key in REQUEST_HEADER_KEYS) {
|
||||
var value = playerHeaders[key]
|
||||
if (value != null) {
|
||||
if (key == AUTHORIZATION_HEADER) {
|
||||
if (!clientType.canLogin) {
|
||||
Logger.printDebug { "Not including request header: $key" }
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (key == VISITOR_ID_HEADER && usePoToken) {
|
||||
val originalVisitorId: String = value
|
||||
Logger.printDebug { "Original visitor id:\n$originalVisitorId" }
|
||||
Logger.printDebug { "Replaced visitor id:\n$visitorId" }
|
||||
value = visitorId
|
||||
}
|
||||
|
||||
connection.setRequestProperty(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
val requestBody: ByteArray
|
||||
if (usePoToken) {
|
||||
requestBody = createApplicationRequestBody(
|
||||
clientType = clientType,
|
||||
videoId = videoId,
|
||||
botGuardPoToken = botGuardPoToken,
|
||||
visitorId = visitorId
|
||||
)
|
||||
if (droidGuardPoToken.isNotEmpty()) {
|
||||
Logger.printDebug { "Original poToken (droidGuardPoToken):\n$droidGuardPoToken" }
|
||||
}
|
||||
Logger.printDebug { "Replaced poToken (botGuardPoToken):\n$botGuardPoToken" }
|
||||
} else {
|
||||
requestBody =
|
||||
createApplicationRequestBody(clientType = clientType, videoId = videoId)
|
||||
}
|
||||
connection.setFixedLengthStreamingMode(requestBody.size)
|
||||
connection.outputStream.write(requestBody)
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
if (responseCode == 200) return connection
|
||||
|
||||
// This situation likely means the patches are outdated.
|
||||
// Use a toast message that suggests updating.
|
||||
handleConnectionError(
|
||||
("Playback error (App is outdated?) " + clientType + ": "
|
||||
+ responseCode + " response: " + connection.responseMessage),
|
||||
null
|
||||
)
|
||||
} catch (ex: SocketTimeoutException) {
|
||||
handleConnectionError("Connection timeout", ex)
|
||||
} catch (ex: IOException) {
|
||||
handleConnectionError("Network error", ex)
|
||||
} catch (ex: Exception) {
|
||||
Logger.printException({ "send failed" }, ex)
|
||||
} finally {
|
||||
Logger.printDebug { "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms" }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun fetch(
|
||||
videoId: String, playerHeaders: Map<String, String>, visitorId: String,
|
||||
botGuardPoToken: String, droidGuardPoToken: String
|
||||
): ByteBuffer? {
|
||||
lastSpoofedClientType = null
|
||||
|
||||
// Retry with different client if empty response body is received.
|
||||
for (clientType in CLIENT_ORDER_TO_USE) {
|
||||
send(
|
||||
clientType,
|
||||
videoId,
|
||||
playerHeaders,
|
||||
visitorId,
|
||||
botGuardPoToken,
|
||||
droidGuardPoToken
|
||||
)?.let { connection ->
|
||||
try {
|
||||
// gzip encoding doesn't response with content length (-1),
|
||||
// but empty response body does.
|
||||
if (connection.contentLength == 0) {
|
||||
Logger.printDebug { "Received empty response\nClient: $clientType\nVideo: $videoId" }
|
||||
} else {
|
||||
BufferedInputStream(connection.inputStream).use { inputStream ->
|
||||
ByteArrayOutputStream().use { stream ->
|
||||
val buffer = ByteArray(2048)
|
||||
var bytesRead: Int
|
||||
while ((inputStream.read(buffer).also { bytesRead = it }) >= 0) {
|
||||
stream.write(buffer, 0, bytesRead)
|
||||
}
|
||||
lastSpoofedClientType = clientType
|
||||
return ByteBuffer.wrap(stream.toByteArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex: IOException) {
|
||||
Logger.printException({ "Fetch failed while processing response data" }, ex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleConnectionError("Could not fetch any client streams", null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
@ -10,13 +10,15 @@ import org.apache.commons.lang3.BooleanUtils;
|
||||
import app.revanced.extension.shared.utils.Logger;
|
||||
import app.revanced.extension.shared.utils.Utils;
|
||||
import app.revanced.extension.youtube.patches.utils.PatchStatus;
|
||||
import app.revanced.extension.youtube.patches.video.requests.PlaylistRequest;
|
||||
import app.revanced.extension.youtube.patches.video.requests.MusicRequest;
|
||||
import app.revanced.extension.youtube.settings.Settings;
|
||||
import app.revanced.extension.youtube.shared.VideoInformation;
|
||||
import app.revanced.extension.youtube.whitelist.Whitelist;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public class PlaybackSpeedPatch {
|
||||
private static final boolean DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC =
|
||||
Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get();
|
||||
private static final long TOAST_DELAY_MILLISECONDS = 750;
|
||||
private static long lastTimeSpeedChanged;
|
||||
private static boolean isLiveStream;
|
||||
@ -39,8 +41,8 @@ public class PlaybackSpeedPatch {
|
||||
/**
|
||||
* Injection point.
|
||||
*/
|
||||
public static void fetchPlaylistData(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) {
|
||||
if (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get()) {
|
||||
public static void fetchMusicRequest(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) {
|
||||
if (DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC) {
|
||||
try {
|
||||
final boolean videoIdIsShort = VideoInformation.lastPlayerResponseIsShort();
|
||||
// Shorts shelf in home and subscription feed causes player response hook to be called,
|
||||
@ -50,9 +52,12 @@ public class PlaybackSpeedPatch {
|
||||
return;
|
||||
}
|
||||
|
||||
PlaylistRequest.fetchRequestIfNeeded(videoId);
|
||||
MusicRequest.fetchRequestIfNeeded(
|
||||
videoId,
|
||||
Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC_TYPE.get()
|
||||
);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "fetchPlaylistData failure", ex);
|
||||
Logger.printException(() -> "fetchMusicRequest failure", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -61,15 +66,16 @@ public class PlaybackSpeedPatch {
|
||||
* Injection point.
|
||||
*/
|
||||
public static float getPlaybackSpeedInShorts(final float playbackSpeed) {
|
||||
if (!VideoInformation.lastPlayerResponseIsShort())
|
||||
return playbackSpeed;
|
||||
if (!Settings.ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS.get())
|
||||
return playbackSpeed;
|
||||
if (VideoInformation.lastPlayerResponseIsShort() &&
|
||||
Settings.ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS.get()
|
||||
) {
|
||||
float defaultPlaybackSpeed = getDefaultPlaybackSpeed(VideoInformation.getChannelId(), null);
|
||||
Logger.printDebug(() -> "overridePlaybackSpeed in Shorts: " + defaultPlaybackSpeed);
|
||||
|
||||
float defaultPlaybackSpeed = getDefaultPlaybackSpeed(VideoInformation.getChannelId(), null);
|
||||
Logger.printDebug(() -> "overridePlaybackSpeed in Shorts: " + defaultPlaybackSpeed);
|
||||
return defaultPlaybackSpeed;
|
||||
}
|
||||
|
||||
return defaultPlaybackSpeed;
|
||||
return playbackSpeed;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -118,23 +124,21 @@ public class PlaybackSpeedPatch {
|
||||
}
|
||||
|
||||
private static float getDefaultPlaybackSpeed(@NonNull String channelId, @Nullable String videoId) {
|
||||
return (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_LIVE.get() && isLiveStream) ||
|
||||
Whitelist.isChannelWhitelistedPlaybackSpeed(channelId) ||
|
||||
getPlaylistData(videoId)
|
||||
return (isLiveStream || Whitelist.isChannelWhitelistedPlaybackSpeed(channelId) || isMusic(videoId))
|
||||
? 1.0f
|
||||
: Settings.DEFAULT_PLAYBACK_SPEED.get();
|
||||
}
|
||||
|
||||
private static boolean getPlaylistData(@Nullable String videoId) {
|
||||
if (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get() && videoId != null) {
|
||||
private static boolean isMusic(@Nullable String videoId) {
|
||||
if (DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC && videoId != null) {
|
||||
try {
|
||||
PlaylistRequest request = PlaylistRequest.getRequestForVideoId(videoId);
|
||||
final boolean isPlaylist = request != null && BooleanUtils.toBoolean(request.getStream());
|
||||
Logger.printDebug(() -> "isPlaylist: " + isPlaylist);
|
||||
MusicRequest request = MusicRequest.getRequestForVideoId(videoId);
|
||||
final boolean isMusic = request != null && BooleanUtils.toBoolean(request.getStream());
|
||||
Logger.printDebug(() -> "videoId: " + videoId + ", isMusic: " + isMusic);
|
||||
|
||||
return isPlaylist;
|
||||
return isMusic;
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "getPlaylistData failure", ex);
|
||||
Logger.printException(() -> "getMusicRequest failure", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,240 @@
|
||||
package app.revanced.extension.youtube.patches.video.requests
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.annotation.GuardedBy
|
||||
import app.revanced.extension.shared.patches.client.AppClient
|
||||
import app.revanced.extension.shared.patches.client.WebClient
|
||||
import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes
|
||||
import app.revanced.extension.shared.requests.Requester
|
||||
import app.revanced.extension.shared.utils.Logger
|
||||
import app.revanced.extension.shared.utils.Utils
|
||||
import app.revanced.extension.youtube.shared.VideoInformation
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
import java.net.SocketTimeoutException
|
||||
import java.util.Objects
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Future
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
class MusicRequest private constructor(private val videoId: String, private val checkCategory: Boolean) {
|
||||
/**
|
||||
* Time this instance and the fetch future was created.
|
||||
*/
|
||||
private val timeFetched = System.currentTimeMillis()
|
||||
private val future: Future<Boolean> = Utils.submitOnBackgroundThread {
|
||||
fetch(
|
||||
videoId,
|
||||
checkCategory,
|
||||
)
|
||||
}
|
||||
|
||||
fun isExpired(now: Long): Boolean {
|
||||
val timeSinceCreation = now - timeFetched
|
||||
if (timeSinceCreation > CACHE_RETENTION_TIME_MILLISECONDS) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Only expired if the fetch failed (API null response).
|
||||
return (fetchCompleted() && stream == null)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return if the fetch call has completed.
|
||||
*/
|
||||
private fun fetchCompleted(): Boolean {
|
||||
return future.isDone
|
||||
}
|
||||
|
||||
val stream: Boolean?
|
||||
get() {
|
||||
try {
|
||||
return future[MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS]
|
||||
} catch (ex: TimeoutException) {
|
||||
Logger.printInfo(
|
||||
{ "getStream timed out" },
|
||||
ex
|
||||
)
|
||||
} catch (ex: InterruptedException) {
|
||||
Logger.printException(
|
||||
{ "getStream interrupted" },
|
||||
ex
|
||||
)
|
||||
Thread.currentThread().interrupt() // Restore interrupt status flag.
|
||||
} catch (ex: ExecutionException) {
|
||||
Logger.printException(
|
||||
{ "getStream failure" },
|
||||
ex
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* How long to keep fetches until they are expired.
|
||||
*/
|
||||
private const val CACHE_RETENTION_TIME_MILLISECONDS = 60 * 1000L // 1 Minute
|
||||
|
||||
private const val MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000L // 20 seconds
|
||||
|
||||
@GuardedBy("itself")
|
||||
private val cache: MutableMap<String, MusicRequest> = HashMap()
|
||||
|
||||
@JvmStatic
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
fun fetchRequestIfNeeded(videoId: String, checkCategory: Boolean) {
|
||||
Objects.requireNonNull(videoId)
|
||||
synchronized(cache) {
|
||||
val now = System.currentTimeMillis()
|
||||
cache.values.removeIf { request: MusicRequest ->
|
||||
val expired = request.isExpired(now)
|
||||
if (expired) Logger.printDebug { "Removing expired stream: " + request.videoId }
|
||||
expired
|
||||
}
|
||||
if (!cache.containsKey(videoId)) {
|
||||
cache[videoId] = MusicRequest(videoId, checkCategory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getRequestForVideoId(videoId: String): MusicRequest? {
|
||||
synchronized(cache) {
|
||||
return cache[videoId]
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleConnectionError(toastMessage: String, ex: Exception?) {
|
||||
Logger.printInfo({ toastMessage }, ex)
|
||||
}
|
||||
|
||||
private fun sendApplicationRequest(videoId: String): JSONObject? {
|
||||
Objects.requireNonNull(videoId)
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val clientType = AppClient.ClientType.ANDROID_VR
|
||||
val clientTypeName = clientType.name
|
||||
Logger.printDebug { "Fetching playlist request for: $videoId using client: $clientTypeName" }
|
||||
|
||||
try {
|
||||
val connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(PlayerRoutes.GET_PLAYLIST_PAGE, clientType.userAgent)
|
||||
val requestBody =
|
||||
PlayerRoutes.createApplicationRequestBody(clientType, videoId, "RD$videoId")
|
||||
|
||||
connection.setFixedLengthStreamingMode(requestBody.size)
|
||||
connection.outputStream.write(requestBody)
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
if (responseCode == 200) return Requester.parseJSONObject(connection)
|
||||
|
||||
handleConnectionError(
|
||||
(clientTypeName + " not available with response code: "
|
||||
+ responseCode + " message: " + connection.responseMessage),
|
||||
null
|
||||
)
|
||||
} catch (ex: SocketTimeoutException) {
|
||||
handleConnectionError("Connection timeout", ex)
|
||||
} catch (ex: IOException) {
|
||||
handleConnectionError("Network error", ex)
|
||||
} catch (ex: Exception) {
|
||||
Logger.printException({ "sendApplicationRequest failed" }, ex)
|
||||
} finally {
|
||||
Logger.printDebug { "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms" }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun sendWebRequest(videoId: String): JSONObject? {
|
||||
Objects.requireNonNull(videoId)
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
val clientType = WebClient.ClientType.MWEB
|
||||
val clientTypeName = clientType.name
|
||||
Logger.printDebug { "Fetching playability request for: $videoId using client: $clientTypeName" }
|
||||
|
||||
try {
|
||||
val connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(PlayerRoutes.GET_CATEGORY, clientType.userAgent)
|
||||
val requestBody =
|
||||
PlayerRoutes.createWebInnertubeBody(clientType, videoId)
|
||||
|
||||
connection.setFixedLengthStreamingMode(requestBody.size)
|
||||
connection.outputStream.write(requestBody)
|
||||
|
||||
val responseCode = connection.responseCode
|
||||
if (responseCode == 200) return Requester.parseJSONObject(connection)
|
||||
|
||||
handleConnectionError(
|
||||
(clientTypeName + " not available with response code: "
|
||||
+ responseCode + " message: " + connection.responseMessage),
|
||||
null
|
||||
)
|
||||
} catch (ex: SocketTimeoutException) {
|
||||
handleConnectionError("Connection timeout", ex)
|
||||
} catch (ex: IOException) {
|
||||
handleConnectionError("Network error", ex)
|
||||
} catch (ex: Exception) {
|
||||
Logger.printException({ "sendWebRequest failed" }, ex)
|
||||
} finally {
|
||||
Logger.printDebug { "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms" }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun parseApplicationResponse(playlistJson: JSONObject): Boolean {
|
||||
try {
|
||||
val playerParams: String? = (playlistJson
|
||||
.getJSONObject("contents")
|
||||
.getJSONObject("singleColumnWatchNextResults")
|
||||
.getJSONObject("playlist")
|
||||
.getJSONObject("playlist")
|
||||
.getJSONArray("contents")[0] as JSONObject)
|
||||
.getJSONObject("playlistPanelVideoRenderer")
|
||||
.getJSONObject("navigationEndpoint")
|
||||
.getJSONObject("watchEndpoint")
|
||||
.getString("playerParams")
|
||||
|
||||
return VideoInformation.isMixPlaylistsOpenedByUser(playerParams!!)
|
||||
} catch (e: JSONException) {
|
||||
Logger.printDebug { "Fetch failed while processing Application response data for response: $playlistJson" }
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun parseWebResponse(microFormatJson: JSONObject): Boolean {
|
||||
try {
|
||||
return microFormatJson
|
||||
.getJSONObject("playerMicroformatRenderer")
|
||||
.getJSONObject("category")
|
||||
.getString("status")
|
||||
.equals("Music")
|
||||
} catch (e: JSONException) {
|
||||
Logger.printDebug { "Fetch failed while processing Web response data for response: $microFormatJson" }
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun fetch(videoId: String, checkCategory: Boolean): Boolean {
|
||||
if (checkCategory) {
|
||||
val microFormatJson = sendWebRequest(videoId)
|
||||
if (microFormatJson != null) {
|
||||
return parseWebResponse(microFormatJson)
|
||||
}
|
||||
} else {
|
||||
val playlistJson = sendApplicationRequest(videoId)
|
||||
if (playlistJson != null) {
|
||||
return parseApplicationResponse(playlistJson)
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
@ -1,205 +0,0 @@
|
||||
package app.revanced.extension.youtube.patches.video.requests;
|
||||
|
||||
import static app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes.GET_PLAYLIST_PAGE;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
|
||||
import androidx.annotation.GuardedBy;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
import app.revanced.extension.shared.patches.client.AppClient.ClientType;
|
||||
import app.revanced.extension.shared.patches.spoof.requests.PlayerRoutes;
|
||||
import app.revanced.extension.shared.requests.Requester;
|
||||
import app.revanced.extension.shared.utils.Logger;
|
||||
import app.revanced.extension.shared.utils.Utils;
|
||||
import app.revanced.extension.youtube.shared.VideoInformation;
|
||||
|
||||
public class PlaylistRequest {
|
||||
|
||||
/**
|
||||
* How long to keep fetches until they are expired.
|
||||
*/
|
||||
private static final long CACHE_RETENTION_TIME_MILLISECONDS = 60 * 1000; // 1 Minute
|
||||
|
||||
private static final long MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000; // 20 seconds
|
||||
|
||||
@GuardedBy("itself")
|
||||
private static final Map<String, PlaylistRequest> cache = new HashMap<>();
|
||||
|
||||
@SuppressLint("ObsoleteSdkInt")
|
||||
public static void fetchRequestIfNeeded(@Nullable String videoId) {
|
||||
Objects.requireNonNull(videoId);
|
||||
synchronized (cache) {
|
||||
final long now = System.currentTimeMillis();
|
||||
|
||||
cache.values().removeIf(request -> {
|
||||
final boolean expired = request.isExpired(now);
|
||||
if (expired) Logger.printDebug(() -> "Removing expired stream: " + request.videoId);
|
||||
return expired;
|
||||
});
|
||||
|
||||
if (!cache.containsKey(videoId)) {
|
||||
cache.put(videoId, new PlaylistRequest(videoId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static PlaylistRequest getRequestForVideoId(@Nullable String videoId) {
|
||||
synchronized (cache) {
|
||||
return cache.get(videoId);
|
||||
}
|
||||
}
|
||||
|
||||
private static void handleConnectionError(String toastMessage, @Nullable Exception ex) {
|
||||
Logger.printInfo(() -> toastMessage, ex);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static JSONObject send(ClientType clientType, String videoId) {
|
||||
Objects.requireNonNull(clientType);
|
||||
Objects.requireNonNull(videoId);
|
||||
|
||||
final long startTime = System.currentTimeMillis();
|
||||
String clientTypeName = clientType.name();
|
||||
Logger.printDebug(() -> "Fetching playlist request for: " + videoId + " using client: " + clientTypeName);
|
||||
|
||||
try {
|
||||
HttpURLConnection connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(GET_PLAYLIST_PAGE, clientType);
|
||||
|
||||
JSONObject innerTubeBodyJson = PlayerRoutes.createInnertubeBody(clientType);
|
||||
innerTubeBodyJson.put("playlistId", "%s");
|
||||
|
||||
String innerTubeBody = String.format(
|
||||
Locale.ENGLISH,
|
||||
innerTubeBodyJson.toString(),
|
||||
videoId,
|
||||
"RD" + videoId
|
||||
);
|
||||
byte[] requestBody = innerTubeBody.getBytes(StandardCharsets.UTF_8);
|
||||
connection.setFixedLengthStreamingMode(requestBody.length);
|
||||
connection.getOutputStream().write(requestBody);
|
||||
|
||||
final int responseCode = connection.getResponseCode();
|
||||
if (responseCode == 200) return Requester.parseJSONObject(connection);
|
||||
|
||||
handleConnectionError(clientTypeName + " not available with response code: "
|
||||
+ responseCode + " message: " + connection.getResponseMessage(),
|
||||
null);
|
||||
} catch (SocketTimeoutException ex) {
|
||||
handleConnectionError("Connection timeout", ex);
|
||||
} catch (IOException ex) {
|
||||
handleConnectionError("Network error", ex);
|
||||
} catch (Exception ex) {
|
||||
Logger.printException(() -> "send failed", ex);
|
||||
} finally {
|
||||
Logger.printDebug(() -> "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Boolean fetch(@NonNull String videoId) {
|
||||
final ClientType clientType = ClientType.ANDROID_VR;
|
||||
final JSONObject playlistJson = send(clientType, videoId);
|
||||
if (playlistJson != null) {
|
||||
try {
|
||||
final JSONObject singleColumnWatchNextResultsJsonObject = playlistJson
|
||||
.getJSONObject("contents")
|
||||
.getJSONObject("singleColumnWatchNextResults");
|
||||
|
||||
if (!singleColumnWatchNextResultsJsonObject.has("playlist")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final JSONObject playlistJsonObject = singleColumnWatchNextResultsJsonObject
|
||||
.getJSONObject("playlist")
|
||||
.getJSONObject("playlist");
|
||||
|
||||
final Object currentStreamObject = playlistJsonObject
|
||||
.getJSONArray("contents")
|
||||
.get(0);
|
||||
|
||||
if (!(currentStreamObject instanceof JSONObject currentStreamJsonObject)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final JSONObject watchEndpointJsonObject = currentStreamJsonObject
|
||||
.getJSONObject("playlistPanelVideoRenderer")
|
||||
.getJSONObject("navigationEndpoint")
|
||||
.getJSONObject("watchEndpoint");
|
||||
|
||||
Logger.printDebug(() -> "watchEndpoint: " + watchEndpointJsonObject);
|
||||
|
||||
return watchEndpointJsonObject.has("playerParams") &&
|
||||
VideoInformation.isMixPlaylistsOpenedByUser(watchEndpointJsonObject.getString("playerParams"));
|
||||
} catch (JSONException e) {
|
||||
Logger.printDebug(() -> "Fetch failed while processing response data for response: " + playlistJson);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Time this instance and the fetch future was created.
|
||||
*/
|
||||
private final long timeFetched;
|
||||
private final String videoId;
|
||||
private final Future<Boolean> future;
|
||||
|
||||
private PlaylistRequest(String videoId) {
|
||||
this.timeFetched = System.currentTimeMillis();
|
||||
this.videoId = videoId;
|
||||
this.future = Utils.submitOnBackgroundThread(() -> fetch(videoId));
|
||||
}
|
||||
|
||||
public boolean isExpired(long now) {
|
||||
final long timeSinceCreation = now - timeFetched;
|
||||
if (timeSinceCreation > CACHE_RETENTION_TIME_MILLISECONDS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Only expired if the fetch failed (API null response).
|
||||
return (fetchCompleted() && getStream() == null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return if the fetch call has completed.
|
||||
*/
|
||||
public boolean fetchCompleted() {
|
||||
return future.isDone();
|
||||
}
|
||||
|
||||
public Boolean getStream() {
|
||||
try {
|
||||
return future.get(MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS);
|
||||
} catch (TimeoutException ex) {
|
||||
Logger.printInfo(() -> "getStream timed out", ex);
|
||||
} catch (InterruptedException ex) {
|
||||
Logger.printException(() -> "getStream interrupted", ex);
|
||||
Thread.currentThread().interrupt(); // Restore interrupt status flag.
|
||||
} catch (ExecutionException ex) {
|
||||
Logger.printException(() -> "getStream failure", ex);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -524,7 +524,6 @@ public class Settings extends BaseSettings {
|
||||
public static final IntegerSetting DEFAULT_VIDEO_QUALITY_MOBILE = new IntegerSetting("revanced_default_video_quality_mobile", -2);
|
||||
public static final IntegerSetting DEFAULT_VIDEO_QUALITY_WIFI = new IntegerSetting("revanced_default_video_quality_wifi", -2);
|
||||
public static final BooleanSetting DISABLE_HDR_VIDEO = new BooleanSetting("revanced_disable_hdr_video", FALSE, true);
|
||||
public static final BooleanSetting DISABLE_DEFAULT_PLAYBACK_SPEED_LIVE = new BooleanSetting("revanced_disable_default_playback_speed_live", TRUE);
|
||||
public static final BooleanSetting ENABLE_CUSTOM_PLAYBACK_SPEED = new BooleanSetting("revanced_enable_custom_playback_speed", FALSE, true);
|
||||
public static final BooleanSetting CUSTOM_PLAYBACK_SPEED_MENU_TYPE = new BooleanSetting("revanced_custom_playback_speed_menu_type", FALSE, parent(ENABLE_CUSTOM_PLAYBACK_SPEED));
|
||||
public static final StringSetting CUSTOM_PLAYBACK_SPEEDS = new StringSetting("revanced_custom_playback_speeds", "0.25\n0.5\n0.75\n1.0\n1.25\n1.5\n1.75\n2.0\n2.25\n2.5", true, parent(ENABLE_CUSTOM_PLAYBACK_SPEED));
|
||||
@ -535,6 +534,7 @@ public class Settings extends BaseSettings {
|
||||
public static final BooleanSetting RESTORE_OLD_VIDEO_QUALITY_MENU = new BooleanSetting("revanced_restore_old_video_quality_menu", TRUE, true);
|
||||
// Experimental Flags
|
||||
public static final BooleanSetting DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC = new BooleanSetting("revanced_disable_default_playback_speed_music", FALSE, true);
|
||||
public static final BooleanSetting DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC_TYPE = new BooleanSetting("revanced_disable_default_playback_speed_music_type", FALSE, true, parent(DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC));
|
||||
public static final BooleanSetting ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS = new BooleanSetting("revanced_enable_default_playback_speed_shorts", FALSE);
|
||||
public static final BooleanSetting SKIP_PRELOADED_BUFFER = new BooleanSetting("revanced_skip_preloaded_buffer", FALSE, true, "revanced_skip_preloaded_buffer_user_dialog_message");
|
||||
public static final BooleanSetting SKIP_PRELOADED_BUFFER_TOAST = new BooleanSetting("revanced_skip_preloaded_buffer_toast", TRUE);
|
||||
|
@ -173,7 +173,7 @@ val videoPlaybackPatch = bytecodePatch(
|
||||
}
|
||||
|
||||
hookBackgroundPlayVideoInformation("$EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR->newVideoStarted(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;JZ)V")
|
||||
hookPlayerResponseVideoId("$EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR->fetchPlaylistData(Ljava/lang/String;Z)V")
|
||||
hookPlayerResponseVideoId("$EXTENSION_PLAYBACK_SPEED_CLASS_DESCRIPTOR->fetchMusicRequest(Ljava/lang/String;Z)V")
|
||||
|
||||
updatePatchStatus(PATCH_STATUS_CLASS_DESCRIPTOR, "RememberPlaybackSpeed")
|
||||
|
||||
|
@ -1497,9 +1497,6 @@ No margins on top and bottom of player."</string>
|
||||
<string name="revanced_disable_hdr_video_title">Disable HDR video</string>
|
||||
<string name="revanced_disable_hdr_video_summary_on">HDR video is disabled.</string>
|
||||
<string name="revanced_disable_hdr_video_summary_off">HDR video is enabled.</string>
|
||||
<string name="revanced_disable_default_playback_speed_live_title">Disable playback speed for live streams</string>
|
||||
<string name="revanced_disable_default_playback_speed_live_summary_on">Default playback speed is disabled for live streams.</string>
|
||||
<string name="revanced_disable_default_playback_speed_live_summary_off">Default playback speed is enabled for live streams.</string>
|
||||
<string name="revanced_enable_custom_playback_speed_title">Enable custom playback speed</string>
|
||||
<string name="revanced_enable_custom_playback_speed_summary_on">Custom playback speed is enabled.</string>
|
||||
<string name="revanced_enable_custom_playback_speed_summary_off">Custom playback speed is disabled.</string>
|
||||
@ -1524,10 +1521,11 @@ No margins on top and bottom of player."</string>
|
||||
<string name="revanced_restore_old_video_quality_menu_summary_on">Old video quality menu is shown.</string>
|
||||
<string name="revanced_restore_old_video_quality_menu_summary_off">Old video quality menu is not shown.</string>
|
||||
<string name="revanced_disable_default_playback_speed_music_title">Disable playback speed for music</string>
|
||||
<string name="revanced_disable_default_playback_speed_music_on">"Default playback speed is disabled for music.
|
||||
|
||||
Limitation: This setting may not apply to videos that do not include the 'Listen on YouTube Music' banner."</string>
|
||||
<string name="revanced_disable_default_playback_speed_music_on">Default playback speed is disabled for music.</string>
|
||||
<string name="revanced_disable_default_playback_speed_music_off">Default playback speed is enabled for music.</string>
|
||||
<string name="revanced_disable_default_playback_speed_music_type_title">Validate using categories</string>
|
||||
<string name="revanced_disable_default_playback_speed_music_type_on">Default playback speed is disabled if the video category is Music.</string>
|
||||
<string name="revanced_disable_default_playback_speed_music_type_off">Default playback speed is disabled for videos playable on YouTube Music.</string>
|
||||
<string name="revanced_enable_default_playback_speed_shorts_title">Enable Shorts default playback speed</string>
|
||||
<string name="revanced_enable_default_playback_speed_shorts_summary_on">Default playback speed applies to Shorts.</string>
|
||||
<string name="revanced_enable_default_playback_speed_shorts_summary_off">Default playback speed does not apply to Shorts.</string>
|
||||
|
@ -684,7 +684,6 @@
|
||||
<ListPreference android:title="@string/revanced_default_video_quality_mobile_title" android:key="revanced_default_video_quality_mobile" android:entries="@array/revanced_default_video_quality_entries" android:entryValues="@array/revanced_default_video_quality_entry_values" />
|
||||
<ListPreference android:title="@string/revanced_default_video_quality_wifi_title" android:key="revanced_default_video_quality_wifi" android:entries="@array/revanced_default_video_quality_entries" android:entryValues="@array/revanced_default_video_quality_entry_values" />
|
||||
<SwitchPreference android:title="@string/revanced_disable_hdr_video_title" android:key="revanced_disable_hdr_video" android:summaryOn="@string/revanced_disable_hdr_video_summary_on" android:summaryOff="@string/revanced_disable_hdr_video_summary_off" />
|
||||
<SwitchPreference android:title="@string/revanced_disable_default_playback_speed_live_title" android:key="revanced_disable_default_playback_speed_live" android:summaryOn="@string/revanced_disable_default_playback_speed_live_summary_on" android:summaryOff="@string/revanced_disable_default_playback_speed_live_summary_off" />
|
||||
<SwitchPreference android:title="@string/revanced_enable_custom_playback_speed_title" android:key="revanced_enable_custom_playback_speed" android:summaryOn="@string/revanced_enable_custom_playback_speed_summary_on" android:summaryOff="@string/revanced_enable_custom_playback_speed_summary_off" />
|
||||
<SwitchPreference android:title="@string/revanced_custom_playback_speed_menu_type_title" android:key="revanced_custom_playback_speed_menu_type" android:summaryOn="@string/revanced_custom_playback_speed_menu_type_summary_on" android:summaryOff="@string/revanced_custom_playback_speed_menu_type_summary_off" />
|
||||
<app.revanced.extension.shared.settings.preference.ResettableEditTextPreference android:title="@string/revanced_custom_playback_speeds_title" android:key="revanced_custom_playback_speeds" android:summary="@string/revanced_custom_playback_speeds_summary" android:inputType="textMultiLine" />
|
||||
@ -695,6 +694,7 @@
|
||||
<SwitchPreference android:title="@string/revanced_restore_old_video_quality_menu_title" android:key="revanced_restore_old_video_quality_menu" android:summaryOn="@string/revanced_restore_old_video_quality_menu_summary_on" android:summaryOff="@string/revanced_restore_old_video_quality_menu_summary_off" />
|
||||
<PreferenceCategory android:title="@string/revanced_preference_category_experimental_flag" android:layout="@layout/revanced_settings_preferences_category"/>
|
||||
<SwitchPreference android:title="@string/revanced_disable_default_playback_speed_music_title" android:key="revanced_disable_default_playback_speed_music" android:summaryOn="@string/revanced_disable_default_playback_speed_music_on" android:summaryOff="@string/revanced_disable_default_playback_speed_music_off" />
|
||||
<SwitchPreference android:title="@string/revanced_disable_default_playback_speed_music_type_title" android:key="revanced_disable_default_playback_speed_music_type" android:summaryOn="@string/revanced_disable_default_playback_speed_music_type_on" android:summaryOff="@string/revanced_disable_default_playback_speed_music_type_off" />
|
||||
<SwitchPreference android:title="@string/revanced_enable_default_playback_speed_shorts_title" android:key="revanced_enable_default_playback_speed_shorts" android:summaryOn="@string/revanced_enable_default_playback_speed_shorts_summary_on" android:summaryOff="@string/revanced_enable_default_playback_speed_shorts_summary_off" />
|
||||
<SwitchPreference android:title="@string/revanced_skip_preloaded_buffer_title" android:key="revanced_skip_preloaded_buffer" android:summary="@string/revanced_skip_preloaded_buffer_summary" />
|
||||
<SwitchPreference android:title="@string/revanced_skip_preloaded_buffer_toast_title" android:key="revanced_skip_preloaded_buffer_toast" android:summaryOn="@string/revanced_skip_preloaded_buffer_toast_summary_on" android:summaryOff="@string/revanced_skip_preloaded_buffer_toast_summary_off" android:dependency="revanced_skip_preloaded_buffer" />
|
||||
|
Loading…
x
Reference in New Issue
Block a user