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.patches.PatchStatus
|
||||||
import app.revanced.extension.shared.settings.BaseSettings
|
import app.revanced.extension.shared.settings.BaseSettings
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to fetch streaming data.
|
||||||
|
*/
|
||||||
object AppClient {
|
object AppClient {
|
||||||
// IOS
|
// IOS
|
||||||
/**
|
/**
|
||||||
@ -46,7 +49,7 @@ object AppClient {
|
|||||||
|
|
||||||
// IOS UNPLUGGED
|
// IOS UNPLUGGED
|
||||||
/**
|
/**
|
||||||
* Video not playable: Paid / Movie
|
* Video not playable: Paid / Movie / Playlists / Music
|
||||||
* Note: Audio track available
|
* Note: Audio track available
|
||||||
*/
|
*/
|
||||||
private const val PACKAGE_NAME_IOS_UNPLUGGED = "com.google.ios.youtubeunplugged"
|
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()
|
return BaseSettings.SPOOF_STREAMING_DATA_IOS_FORCE_AVC.get()
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
|
||||||
val availableClientTypes: Array<ClientType>
|
val availableClientTypes: Array<ClientType>
|
||||||
get() = if (PatchStatus.SpoofStreamingDataMusic())
|
get() = if (PatchStatus.SpoofStreamingDataMusic())
|
||||||
ClientType.CLIENT_ORDER_TO_USE_YOUTUBE_MUSIC
|
ClientType.CLIENT_ORDER_TO_USE_YOUTUBE_MUSIC
|
||||||
@ -185,43 +187,35 @@ object AppClient {
|
|||||||
/**
|
/**
|
||||||
* Device model, equivalent to [Build.MODEL] (System property: ro.product.model)
|
* 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)
|
* 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.
|
* Client user-agent.
|
||||||
*/
|
*/
|
||||||
@JvmField
|
|
||||||
val userAgent: String,
|
val userAgent: String,
|
||||||
/**
|
/**
|
||||||
* Android SDK version, equivalent to [Build.VERSION.SDK] (System property: ro.build.version.sdk)
|
* Android SDK version, equivalent to [Build.VERSION.SDK] (System property: ro.build.version.sdk)
|
||||||
* Field is null if not applicable.
|
* Field is null if not applicable.
|
||||||
*/
|
*/
|
||||||
@JvmField
|
|
||||||
val androidSdkVersion: String? = null,
|
val androidSdkVersion: String? = null,
|
||||||
/**
|
/**
|
||||||
* App version.
|
* App version.
|
||||||
*/
|
*/
|
||||||
@JvmField
|
|
||||||
val clientVersion: String,
|
val clientVersion: String,
|
||||||
/**
|
/**
|
||||||
* If the client can access the API logged in.
|
* 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 requirePoToken: Boolean = false,
|
||||||
val usePoToken: Boolean? = false,
|
|
||||||
/**
|
/**
|
||||||
* Friendly name displayed in stats for nerds.
|
* Friendly name displayed in stats for nerds.
|
||||||
*/
|
*/
|
||||||
@JvmField
|
|
||||||
val friendlyName: String
|
val friendlyName: String
|
||||||
) {
|
) {
|
||||||
ANDROID_VR(
|
ANDROID_VR(
|
||||||
@ -260,7 +254,7 @@ object AppClient {
|
|||||||
userAgent = USER_AGENT_IOS,
|
userAgent = USER_AGENT_IOS,
|
||||||
clientVersion = CLIENT_VERSION_IOS,
|
clientVersion = CLIENT_VERSION_IOS,
|
||||||
canLogin = false,
|
canLogin = false,
|
||||||
usePoToken = true,
|
requirePoToken = true,
|
||||||
friendlyName = if (forceAVC())
|
friendlyName = if (forceAVC())
|
||||||
"iOS Force AVC"
|
"iOS Force AVC"
|
||||||
else
|
else
|
||||||
@ -274,7 +268,6 @@ object AppClient {
|
|||||||
friendlyName = "Android Music"
|
friendlyName = "Android Music"
|
||||||
);
|
);
|
||||||
|
|
||||||
@JvmField
|
|
||||||
val clientName: String = name
|
val clientName: String = name
|
||||||
|
|
||||||
companion object {
|
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);
|
final Long approxDurationMs = approxDurationMsMap.get(videoId);
|
||||||
if (approxDurationMs != null) {
|
if (approxDurationMs != null) {
|
||||||
Logger.printDebug(() -> "Replacing video length: " + approxDurationMs + " for videoId: " + videoId);
|
Logger.printDebug(() -> "Replacing video length: " + approxDurationMs + " for videoId: " + videoId);
|
||||||
approxDurationMsMap.remove(videoId);
|
|
||||||
return approxDurationMs;
|
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.Logger;
|
||||||
import app.revanced.extension.shared.utils.Utils;
|
import app.revanced.extension.shared.utils.Utils;
|
||||||
import app.revanced.extension.youtube.patches.utils.PatchStatus;
|
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.settings.Settings;
|
||||||
import app.revanced.extension.youtube.shared.VideoInformation;
|
import app.revanced.extension.youtube.shared.VideoInformation;
|
||||||
import app.revanced.extension.youtube.whitelist.Whitelist;
|
import app.revanced.extension.youtube.whitelist.Whitelist;
|
||||||
|
|
||||||
@SuppressWarnings("unused")
|
@SuppressWarnings("unused")
|
||||||
public class PlaybackSpeedPatch {
|
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 final long TOAST_DELAY_MILLISECONDS = 750;
|
||||||
private static long lastTimeSpeedChanged;
|
private static long lastTimeSpeedChanged;
|
||||||
private static boolean isLiveStream;
|
private static boolean isLiveStream;
|
||||||
@ -39,8 +41,8 @@ public class PlaybackSpeedPatch {
|
|||||||
/**
|
/**
|
||||||
* Injection point.
|
* Injection point.
|
||||||
*/
|
*/
|
||||||
public static void fetchPlaylistData(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) {
|
public static void fetchMusicRequest(@NonNull String videoId, boolean isShortAndOpeningOrPlaying) {
|
||||||
if (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get()) {
|
if (DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC) {
|
||||||
try {
|
try {
|
||||||
final boolean videoIdIsShort = VideoInformation.lastPlayerResponseIsShort();
|
final boolean videoIdIsShort = VideoInformation.lastPlayerResponseIsShort();
|
||||||
// Shorts shelf in home and subscription feed causes player response hook to be called,
|
// Shorts shelf in home and subscription feed causes player response hook to be called,
|
||||||
@ -50,9 +52,12 @@ public class PlaybackSpeedPatch {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaylistRequest.fetchRequestIfNeeded(videoId);
|
MusicRequest.fetchRequestIfNeeded(
|
||||||
|
videoId,
|
||||||
|
Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC_TYPE.get()
|
||||||
|
);
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Logger.printException(() -> "fetchPlaylistData failure", ex);
|
Logger.printException(() -> "fetchMusicRequest failure", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -61,15 +66,16 @@ public class PlaybackSpeedPatch {
|
|||||||
* Injection point.
|
* Injection point.
|
||||||
*/
|
*/
|
||||||
public static float getPlaybackSpeedInShorts(final float playbackSpeed) {
|
public static float getPlaybackSpeedInShorts(final float playbackSpeed) {
|
||||||
if (!VideoInformation.lastPlayerResponseIsShort())
|
if (VideoInformation.lastPlayerResponseIsShort() &&
|
||||||
return playbackSpeed;
|
Settings.ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS.get()
|
||||||
if (!Settings.ENABLE_DEFAULT_PLAYBACK_SPEED_SHORTS.get())
|
) {
|
||||||
return playbackSpeed;
|
float defaultPlaybackSpeed = getDefaultPlaybackSpeed(VideoInformation.getChannelId(), null);
|
||||||
|
Logger.printDebug(() -> "overridePlaybackSpeed in Shorts: " + defaultPlaybackSpeed);
|
||||||
|
|
||||||
float defaultPlaybackSpeed = getDefaultPlaybackSpeed(VideoInformation.getChannelId(), null);
|
return defaultPlaybackSpeed;
|
||||||
Logger.printDebug(() -> "overridePlaybackSpeed in Shorts: " + defaultPlaybackSpeed);
|
}
|
||||||
|
|
||||||
return defaultPlaybackSpeed;
|
return playbackSpeed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -118,23 +124,21 @@ public class PlaybackSpeedPatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static float getDefaultPlaybackSpeed(@NonNull String channelId, @Nullable String videoId) {
|
private static float getDefaultPlaybackSpeed(@NonNull String channelId, @Nullable String videoId) {
|
||||||
return (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_LIVE.get() && isLiveStream) ||
|
return (isLiveStream || Whitelist.isChannelWhitelistedPlaybackSpeed(channelId) || isMusic(videoId))
|
||||||
Whitelist.isChannelWhitelistedPlaybackSpeed(channelId) ||
|
|
||||||
getPlaylistData(videoId)
|
|
||||||
? 1.0f
|
? 1.0f
|
||||||
: Settings.DEFAULT_PLAYBACK_SPEED.get();
|
: Settings.DEFAULT_PLAYBACK_SPEED.get();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean getPlaylistData(@Nullable String videoId) {
|
private static boolean isMusic(@Nullable String videoId) {
|
||||||
if (Settings.DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC.get() && videoId != null) {
|
if (DISABLE_DEFAULT_PLAYBACK_SPEED_MUSIC && videoId != null) {
|
||||||
try {
|
try {
|
||||||
PlaylistRequest request = PlaylistRequest.getRequestForVideoId(videoId);
|
MusicRequest request = MusicRequest.getRequestForVideoId(videoId);
|
||||||
final boolean isPlaylist = request != null && BooleanUtils.toBoolean(request.getStream());
|
final boolean isMusic = request != null && BooleanUtils.toBoolean(request.getStream());
|
||||||
Logger.printDebug(() -> "isPlaylist: " + isPlaylist);
|
Logger.printDebug(() -> "videoId: " + videoId + ", isMusic: " + isMusic);
|
||||||
|
|
||||||
return isPlaylist;
|
return isMusic;
|
||||||
} catch (Exception ex) {
|
} 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_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 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_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 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 BooleanSetting CUSTOM_PLAYBACK_SPEED_MENU_TYPE = new BooleanSetting("revanced_custom_playback_speed_menu_type", FALSE, parent(ENABLE_CUSTOM_PLAYBACK_SPEED));
|
||||||
public static final StringSetting CUSTOM_PLAYBACK_SPEEDS = new StringSetting("revanced_custom_playback_speeds", "0.25\n0.5\n0.75\n1.0\n1.25\n1.5\n1.75\n2.0\n2.25\n2.5", true, parent(ENABLE_CUSTOM_PLAYBACK_SPEED));
|
public static final 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);
|
public static final BooleanSetting RESTORE_OLD_VIDEO_QUALITY_MENU = new BooleanSetting("revanced_restore_old_video_quality_menu", TRUE, true);
|
||||||
// Experimental Flags
|
// 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 = 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 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 = new BooleanSetting("revanced_skip_preloaded_buffer", FALSE, true, "revanced_skip_preloaded_buffer_user_dialog_message");
|
||||||
public static final BooleanSetting SKIP_PRELOADED_BUFFER_TOAST = new BooleanSetting("revanced_skip_preloaded_buffer_toast", TRUE);
|
public static final BooleanSetting 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")
|
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")
|
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_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_on">HDR video is disabled.</string>
|
||||||
<string name="revanced_disable_hdr_video_summary_off">HDR video is enabled.</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_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_on">Custom playback speed is enabled.</string>
|
||||||
<string name="revanced_enable_custom_playback_speed_summary_off">Custom playback speed is disabled.</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_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_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_title">Disable playback speed for music</string>
|
||||||
<string name="revanced_disable_default_playback_speed_music_on">"Default playback speed is disabled for music.
|
<string name="revanced_disable_default_playback_speed_music_on">Default playback speed is disabled for music.</string>
|
||||||
|
|
||||||
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_off">Default playback speed is enabled 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_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_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>
|
<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_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" />
|
<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_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_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" />
|
<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" />
|
<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" />
|
<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"/>
|
<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_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_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_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" />
|
<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