feat(YouTube): Add Open channel of live avatar patch

This commit is contained in:
inotia00
2025-01-18 20:22:09 +09:00
parent 439f4976bc
commit 9b299b41f5
13 changed files with 427 additions and 7 deletions

View File

@ -44,6 +44,14 @@ object PlayerRoutes {
"&alt=proto"
).compile()
@JvmField
val GET_VIDEO_DETAILS: CompiledRoute = Route(
Route.Method.POST,
"player" +
"?prettyPrint=false" +
"&fields=videoDetails.channelId"
).compile()
private const val YT_API_URL = "https://youtubei.googleapis.com/youtubei/v1/"
/**

View File

@ -0,0 +1,61 @@
package app.revanced.extension.youtube.patches.general;
import app.revanced.extension.shared.utils.Logger;
import app.revanced.extension.youtube.patches.general.requests.VideoDetailsRequest;
import app.revanced.extension.youtube.settings.Settings;
import app.revanced.extension.youtube.utils.VideoUtils;
@SuppressWarnings("unused")
public final class OpenChannelOfLiveAvatarPatch {
private static final boolean OPEN_CHANNEL_OF_LIVE_AVATAR =
Settings.OPEN_CHANNEL_OF_LIVE_AVATAR.get();
private static volatile String videoId = "";
private static volatile boolean liveChannelAvatarClicked = false;
public static void liveChannelAvatarClicked() {
liveChannelAvatarClicked = true;
}
public static boolean openChannelOfLiveAvatar() {
try {
if (!OPEN_CHANNEL_OF_LIVE_AVATAR) {
return false;
}
if (!liveChannelAvatarClicked) {
return false;
}
VideoDetailsRequest request = VideoDetailsRequest.getRequestForVideoId(videoId);
if (request != null) {
String channelId = request.getInfo();
if (channelId != null) {
liveChannelAvatarClicked = false;
VideoUtils.openChannel(channelId);
return true;
}
}
} catch (Exception ex) {
Logger.printException(() -> "openChannelOfLiveAvatar failure", ex);
}
return false;
}
public static void openChannelOfLiveAvatar(String newlyLoadedVideoId) {
try {
if (!OPEN_CHANNEL_OF_LIVE_AVATAR) {
return;
}
if (newlyLoadedVideoId.isEmpty()) {
return;
}
if (!liveChannelAvatarClicked) {
return;
}
videoId = newlyLoadedVideoId;
VideoDetailsRequest.fetchRequestIfNeeded(newlyLoadedVideoId);
} catch (Exception ex) {
Logger.printException(() -> "openChannelOfLiveAvatar failure", ex);
}
}
}

View File

@ -0,0 +1,141 @@
package app.revanced.extension.youtube.patches.general.requests
import android.annotation.SuppressLint
import androidx.annotation.GuardedBy
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 org.json.JSONException
import org.json.JSONObject
import java.io.IOException
import java.net.SocketTimeoutException
import java.util.Collections
import java.util.concurrent.ExecutionException
import java.util.concurrent.Future
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
class VideoDetailsRequest private constructor(
private val videoId: String
) {
private val future: Future<String> = Utils.submitOnBackgroundThread {
fetch(videoId)
}
val info: String?
get() {
try {
return future[MAX_MILLISECONDS_TO_WAIT_FOR_FETCH, TimeUnit.MILLISECONDS]
} catch (ex: TimeoutException) {
Logger.printInfo(
{ "getInfo timed out" },
ex
)
} catch (ex: InterruptedException) {
Logger.printException(
{ "getInfo interrupted" },
ex
)
Thread.currentThread().interrupt() // Restore interrupt status flag.
} catch (ex: ExecutionException) {
Logger.printException(
{ "getInfo failure" },
ex
)
}
return null
}
companion object {
private const val MAX_MILLISECONDS_TO_WAIT_FOR_FETCH = 20 * 1000L // 20 seconds
@GuardedBy("itself")
val cache: MutableMap<String, VideoDetailsRequest> = Collections.synchronizedMap(
object : LinkedHashMap<String, VideoDetailsRequest>(100) {
private val CACHE_LIMIT = 50
override fun removeEldestEntry(eldest: Map.Entry<String, VideoDetailsRequest>): Boolean {
return size > CACHE_LIMIT // Evict the oldest entry if over the cache limit.
}
})
@JvmStatic
@SuppressLint("ObsoleteSdkInt")
fun fetchRequestIfNeeded(videoId: String) {
cache[videoId] = VideoDetailsRequest(videoId)
}
@JvmStatic
fun getRequestForVideoId(videoId: String): VideoDetailsRequest? {
synchronized(cache) {
return cache[videoId]
}
}
private fun handleConnectionError(toastMessage: String, ex: Exception?) {
Logger.printInfo({ toastMessage }, ex)
}
private fun sendRequest(videoId: String): JSONObject? {
val startTime = System.currentTimeMillis()
val clientType = WebClient.ClientType.MWEB
val clientTypeName = clientType.name
Logger.printDebug { "Fetching video details request for: $videoId, using client: $clientTypeName" }
try {
val connection = PlayerRoutes.getPlayerResponseConnectionFromRoute(
PlayerRoutes.GET_VIDEO_DETAILS,
clientType
)
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({ "sendRequest failed" }, ex)
} finally {
Logger.printDebug { "video: " + videoId + " took: " + (System.currentTimeMillis() - startTime) + "ms" }
}
return null
}
private fun parseResponse(videoDetailsJson: JSONObject): String? {
try {
return videoDetailsJson
.getJSONObject("videoDetails")
.getString("channelId")
} catch (e: JSONException) {
Logger.printException ({ "Fetch failed while processing response data for response: $videoDetailsJson" }, e)
}
return null
}
private fun fetch(videoId: String): String? {
val videoDetailsJson = sendRequest(videoId)
if (videoDetailsJson != null) {
return parseResponse(videoDetailsJson)
}
return null
}
}
}

View File

@ -156,6 +156,7 @@ public class Settings extends BaseSettings {
public static final BooleanSetting REMOVE_VIEWER_DISCRETION_DIALOG = new BooleanSetting("revanced_remove_viewer_discretion_dialog", FALSE);
public static final EnumSetting<FormFactor> CHANGE_LAYOUT = new EnumSetting<>("revanced_change_layout", FormFactor.ORIGINAL, true);
public static final BooleanSetting OPEN_CHANNEL_OF_LIVE_AVATAR = new BooleanSetting("revanced_open_channel_of_live_avatar", FALSE, true);
public static final BooleanSetting SPOOF_APP_VERSION = new BooleanSetting("revanced_spoof_app_version", false, true, "revanced_spoof_app_version_user_dialog_message");
public static final StringSetting SPOOF_APP_VERSION_TARGET = new StringSetting("revanced_spoof_app_version_target", PatchStatus.SpoofAppVersionDefaultString(), true, parent(SPOOF_APP_VERSION));

View File

@ -29,12 +29,17 @@ import app.revanced.extension.youtube.shared.VideoInformation;
@SuppressWarnings("unused")
public class VideoUtils extends IntentUtils {
private static final String CHANNEL_URL = "https://www.youtube.com/channel/";
private static final String PLAYLIST_URL = "https://www.youtube.com/playlist?list=";
private static final String VIDEO_URL = "https://youtu.be/";
private static final String VIDEO_SCHEME_INTENT_FORMAT = "vnd.youtube://%s?start=%d";
private static final String VIDEO_SCHEME_LINK_FORMAT = "https://youtu.be/%s?t=%d";
private static final AtomicBoolean isExternalDownloaderLaunched = new AtomicBoolean(false);
private static String getChannelUrl(String channelId) {
return CHANNEL_URL + channelId;
}
private static String getPlaylistUrl(String playlistId) {
return PLAYLIST_URL + playlistId;
}
@ -119,6 +124,10 @@ public class VideoUtils extends IntentUtils {
}
}
public static void openChannel(@NonNull String channelId) {
launchView(getChannelUrl(channelId), getContext().getPackageName());
}
public static void openVideo() {
openVideo(VideoInformation.getVideoId());
}

View File

@ -0,0 +1,42 @@
package app.revanced.patches.youtube.general.channel
import app.revanced.patches.youtube.utils.resourceid.elementsImage
import app.revanced.util.fingerprint.legacyFingerprint
import app.revanced.util.getReference
import app.revanced.util.indexOfFirstInstruction
import app.revanced.util.or
import com.android.tools.smali.dexlib2.AccessFlags
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.Method
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
internal val elementsImageFingerprint = legacyFingerprint(
name = "elementsImageFingerprint",
returnType = "Landroid/view/View;",
accessFlags = AccessFlags.PRIVATE or AccessFlags.STATIC,
parameters = listOf("Landroid/view/View;"),
literals = listOf(elementsImage),
)
internal val clientSettingEndpointFingerprint = legacyFingerprint(
name = "clientSettingEndpointFingerprint",
returnType = "V",
accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
parameters = listOf("L", "Ljava/util/Map;"),
strings = listOf(
"force_fullscreen",
"PLAYBACK_START_DESCRIPTOR_MUTATOR",
"VideoPresenterConstants.VIDEO_THUMBNAIL_BITMAP_KEY"
),
customFingerprint = { method, _ ->
indexOfPlaybackStartDescriptorInstruction(method) >= 0
}
)
internal fun indexOfPlaybackStartDescriptorInstruction(method: Method) =
method.indexOfFirstInstruction {
val reference = getReference<MethodReference>()
opcode == Opcode.INVOKE_VIRTUAL &&
reference?.returnType == "Lcom/google/android/libraries/youtube/player/model/PlaybackStartDescriptor;" &&
reference.parameterTypes.isEmpty()
}

View File

@ -0,0 +1,90 @@
package app.revanced.patches.youtube.general.channel
import app.revanced.patcher.extensions.InstructionExtensions.addInstruction
import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
import app.revanced.patcher.extensions.InstructionExtensions.addInstructionsWithLabels
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.youtube.utils.compatibility.Constants.COMPATIBLE_PACKAGE
import app.revanced.patches.youtube.utils.extension.Constants.GENERAL_PATH
import app.revanced.patches.youtube.utils.patch.PatchList.OPEN_CHANNEL_OF_LIVE_AVATAR
import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch
import app.revanced.patches.youtube.utils.settings.ResourceUtils.addPreference
import app.revanced.patches.youtube.utils.settings.settingsPatch
import app.revanced.patches.youtube.video.playbackstart.playbackStartDescriptorPatch
import app.revanced.patches.youtube.video.playbackstart.playbackStartVideoIdReference
import app.revanced.util.fingerprint.methodOrThrow
import app.revanced.util.indexOfFirstInstructionOrThrow
import app.revanced.util.indexOfFirstInstructionReversedOrThrow
import com.android.tools.smali.dexlib2.Opcode
import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction
private const val EXTENSION_CLASS_DESCRIPTOR =
"$GENERAL_PATH/OpenChannelOfLiveAvatarPatch;"
@Suppress("unused")
val layoutSwitchPatch = bytecodePatch(
OPEN_CHANNEL_OF_LIVE_AVATAR.title,
OPEN_CHANNEL_OF_LIVE_AVATAR.summary,
) {
compatibleWith(COMPATIBLE_PACKAGE)
dependsOn(
playbackStartDescriptorPatch,
sharedResourceIdPatch,
settingsPatch,
)
execute {
elementsImageFingerprint.methodOrThrow().addInstruction(
0,
"invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->liveChannelAvatarClicked()V"
)
clientSettingEndpointFingerprint.methodOrThrow().apply {
val eqzIndex = indexOfFirstInstructionReversedOrThrow(Opcode.IF_EQZ)
var freeIndex = indexOfFirstInstructionReversedOrThrow(eqzIndex, Opcode.NEW_INSTANCE)
var freeRegister = getInstruction<OneRegisterInstruction>(freeIndex).registerA
addInstructionsWithLabels(
eqzIndex, """
invoke-static { }, $EXTENSION_CLASS_DESCRIPTOR->openChannelOfLiveAvatar()Z
move-result v$freeRegister
if-eqz v$freeRegister, :ignore
return-void
:ignore
nop
"""
)
val playbackStartIndex = indexOfPlaybackStartDescriptorInstruction(this) + 1
val playbackStartRegister = getInstruction<OneRegisterInstruction>(playbackStartIndex).registerA
freeIndex = indexOfFirstInstructionOrThrow(playbackStartIndex, Opcode.CONST_STRING)
freeRegister = getInstruction<OneRegisterInstruction>(freeIndex).registerA
addInstructions(
playbackStartIndex + 1, """
invoke-virtual { v$playbackStartRegister }, $playbackStartVideoIdReference
move-result-object v$freeRegister
invoke-static { v$freeRegister }, $EXTENSION_CLASS_DESCRIPTOR->openChannelOfLiveAvatar(Ljava/lang/String;)V
"""
)
}
// region add settings
addPreference(
arrayOf(
"PREFERENCE_SCREEN: GENERAL",
"PREFERENCE_CATEGORY: GENERAL_EXPERIMENTAL_FLAGS",
"SETTINGS: OPEN_CHANNEL_OF_LIVE_AVATAR"
),
OPEN_CHANNEL_OF_LIVE_AVATAR
)
// endregion
}
}

View File

@ -169,6 +169,10 @@ internal enum class PatchList(
"Navigation bar components",
"Adds options to hide or change components related to the navigation bar."
),
OPEN_CHANNEL_OF_LIVE_AVATAR(
"Open channel of live avatar",
"Adds an option to open channel instead of video when clicking on live avatar."
),
OPEN_LINKS_EXTERNALLY(
"Open links externally",
"Adds an option to always open links in your browser instead of in the in-app-browser."

View File

@ -83,6 +83,8 @@ var easySeekEduContainer = -1L
private set
var editSettingsAction = -1L
private set
var elementsImage = -1L
private set
var endScreenElementLayoutCircle = -1L
private set
var endScreenElementLayoutIcon = -1L
@ -383,6 +385,10 @@ internal val sharedResourceIdPatch = resourcePatch(
STRING,
"edit_settings_action"
]
elementsImage = resourceMappings[
ID,
"elements_image"
]
endScreenElementLayoutCircle = resourceMappings[
LAYOUT,
"endscreen_element_layout_circle"

View File

@ -0,0 +1,19 @@
package app.revanced.patches.youtube.video.playbackstart
import app.revanced.util.fingerprint.legacyFingerprint
const val PLAYBACK_START_DESCRIPTOR_CLASS_DESCRIPTOR =
"Lcom/google/android/libraries/youtube/player/model/PlaybackStartDescriptor;"
/**
* Purpose of this method is not clear, and it's only used to identify
* the obfuscated name of the videoId() method in PlaybackStartDescriptor.
*/
internal val playbackStartFeatureFlagFingerprint = legacyFingerprint(
name = "playbackStartFeatureFlagFingerprint",
returnType = "Z",
parameters = listOf(PLAYBACK_START_DESCRIPTOR_CLASS_DESCRIPTOR),
literals = listOf(45380134L)
)

View File

@ -0,0 +1,33 @@
package app.revanced.patches.youtube.video.playbackstart
import app.revanced.patcher.extensions.InstructionExtensions.getInstruction
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.youtube.utils.resourceid.sharedResourceIdPatch
import app.revanced.util.fingerprint.methodOrThrow
import app.revanced.util.getReference
import app.revanced.util.indexOfFirstInstructionOrThrow
import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
import com.android.tools.smali.dexlib2.iface.reference.Reference
internal lateinit var playbackStartVideoIdReference: Reference
val playbackStartDescriptorPatch = bytecodePatch(
description = "playbackStartDescriptorPatch"
) {
dependsOn(sharedResourceIdPatch)
execute {
// Find the obfuscated method name for PlaybackStartDescriptor.videoId()
playbackStartFeatureFlagFingerprint.methodOrThrow().apply {
val stringMethodIndex = indexOfFirstInstructionOrThrow {
val reference = getReference<MethodReference>()
reference?.definingClass == PLAYBACK_START_DESCRIPTOR_CLASS_DESCRIPTOR
&& reference.returnType == "Ljava/lang/String;"
}
playbackStartVideoIdReference = getInstruction<ReferenceInstruction>(stringMethodIndex).reference
}
}
}

View File

@ -373,13 +373,6 @@ If the layout of the player screen changes due to server-side changes, unintende
<!-- PreferenceScreen: General -->
<string name="revanced_preference_screen_general_title">General</string>
<string name="revanced_change_layout_title">Change layout</string>
<string name="revanced_change_layout_entry_1">Original</string>
<string name="revanced_change_layout_entry_2">Phone</string>
<string name="revanced_change_layout_entry_3">Phone (Max 480 dp)</string>
<string name="revanced_change_layout_entry_4">Tablet</string>
<string name="revanced_change_layout_entry_5">Tablet (Min 600 dp)</string>
<string name="revanced_change_start_page_title">Change start page</string>
<string name="revanced_change_start_page_entry_default">Default</string>
<string name="revanced_change_start_page_entry_browse">Browse channels</string>
@ -438,6 +431,15 @@ Limitation: Back button on the toolbar may not work."</string>
<string name="revanced_remove_viewer_discretion_dialog_summary">"Removes the viewer discretion dialog.
This does not bypass the age restriction. It just accepts it automatically."</string>
<string name="revanced_change_layout_title">Change layout</string>
<string name="revanced_change_layout_entry_1">Original</string>
<string name="revanced_change_layout_entry_2">Phone</string>
<string name="revanced_change_layout_entry_3">Phone (Max 480 dp)</string>
<string name="revanced_change_layout_entry_4">Tablet</string>
<string name="revanced_change_layout_entry_5">Tablet (Min 600 dp)</string>
<string name="revanced_open_channel_of_live_avatar_title">Open channel of live avatar</string>
<string name="revanced_open_channel_of_live_avatar_summary_on">Channel opens when the live avatar is clicked.</string>
<string name="revanced_open_channel_of_live_avatar_summary_off">Live stream opens when the live avatar is clicked.</string>
<string name="revanced_spoof_app_version_title">Spoof app version</string>
<string name="revanced_spoof_app_version_summary_on">Version spoofed</string>
<string name="revanced_spoof_app_version_summary_off">Version not spoofed</string>

View File

@ -304,6 +304,9 @@
<!-- SETTINGS: LAYOUT_SWITCH
<ListPreference android:entries="@array/revanced_change_layout_entries" android:title="@string/revanced_change_layout_title" android:key="revanced_change_layout" android:entryValues="@array/revanced_change_layout_entry_values" />SETTINGS: LAYOUT_SWITCH -->
<!-- SETTINGS: OPEN_CHANNEL_OF_LIVE_AVATAR
<SwitchPreference android:title="@string/revanced_open_channel_of_live_avatar_title" android:key="revanced_open_channel_of_live_avatar" android:summaryOn="@string/revanced_open_channel_of_live_avatar_summary_on" android:summaryOff="@string/revanced_open_channel_of_live_avatar_summary_off" />SETTINGS: OPEN_CHANNEL_OF_LIVE_AVATAR -->
<!-- SETTINGS: SPOOF_APP_VERSION
<SwitchPreference android:title="@string/revanced_spoof_app_version_title" android:key="revanced_spoof_app_version" android:summaryOn="@string/revanced_spoof_app_version_summary_on" android:summaryOff="@string/revanced_spoof_app_version_summary_off" />
<app.revanced.extension.shared.settings.preference.WideListPreference android:title="@string/revanced_spoof_app_version_target_entry_title" android:key="revanced_spoof_app_version_target" android:entries="@array/revanced_spoof_app_version_target_entries" android:entryValues="@array/revanced_spoof_app_version_target_entry_values" />
@ -885,6 +888,7 @@
<Preference android:title="Layout switch" android:summary="@string/revanced_patches_excluded" android:selectable="false"/>
<Preference android:title="Miniplayer" android:summary="@string/revanced_patches_excluded" android:selectable="false"/>
<Preference android:title="Navigation bar components" android:summary="@string/revanced_patches_excluded" android:selectable="false"/>
<Preference android:title="Open channel of live avatar" android:summary="@string/revanced_patches_excluded" android:selectable="false"/>
<Preference android:title="Remove viewer discretion dialog" android:summary="@string/revanced_patches_excluded" android:selectable="false"/>
<Preference android:title="Spoof app version" android:summary="@string/revanced_patches_excluded" android:selectable="false"/>
<Preference android:title="Toolbar components" android:summary="@string/revanced_patches_excluded" android:selectable="false"/>