feat(YouTube Music): Add Spoof player parameter patch https://github.com/inotia00/ReVanced_Extended/issues/2832

This commit is contained in:
inotia00 2025-03-14 18:18:53 +09:00
parent 806976b6d8
commit d4af0f1ee0
11 changed files with 469 additions and 17 deletions

View File

@ -0,0 +1,174 @@
package app.revanced.extension.music.patches.misc;
import static app.revanced.extension.music.shared.VideoInformation.parameterIsAgeRestricted;
import static app.revanced.extension.music.shared.VideoInformation.parameterIsSample;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.apache.commons.lang3.BooleanUtils;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
import app.revanced.extension.music.settings.Settings;
import app.revanced.extension.music.shared.VideoInformation;
import app.revanced.extension.shared.utils.Logger;
@SuppressWarnings("unused")
public class SpoofPlayerParameterPatch {
/**
* Used in YouTube Music.
*/
private static final boolean SPOOF_PLAYER_PARAMETER = Settings.SPOOF_PLAYER_PARAMETER.get();
/**
* Parameter to fix playback issues.
* Used in YouTube Music Samples.
*/
private static final String PLAYER_PARAMETER_SAMPLES =
"8AEB2AUBogYVAUY4C8W9wrM-FdhjSW4MnCgH44uhkAcI";
/**
* Parameter to fix playback issues.
* Used in YouTube Shorts.
*/
private static final String PLAYER_PARAMETER_SHORTS =
"8AEByAMkuAQ0ogYVAePzwRN3uesV1sPI2x4-GkDYlvqUkAcC";
/**
* On app first start, the first video played usually contains a single non-default window setting value
* and all other subtitle settings for the video are (incorrect) default Samples window settings.
* For this situation, the Samples settings must be replaced.
* <p>
* But some videos use multiple text positions on screen (such as youtu.be/3hW1rMNC89o),
* and by chance many of the subtitles uses window positions that match a default Samples position.
* To handle these videos, selectively allowing the Samples specific window settings to 'pass thru' unchanged,
* but only if the video contains multiple non-default subtitle window positions.
* <p>
* Do not enable 'pass thru mode' until this many non default subtitle settings are observed for a single video.
*/
private static final int NUMBER_OF_NON_DEFAULT_SUBTITLES_BEFORE_ENABLING_PASSTHRU = 2;
/**
* The number of non default subtitle settings encountered for the current video.
*/
private static int numberOfNonDefaultSettingsObserved;
@GuardedBy("itself")
private static final Map<String, Boolean> lastVideoIds = new LinkedHashMap<>() {
private static final int NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK = 5;
@Override
protected boolean removeEldestEntry(Entry eldest) {
return size() > NUMBER_OF_LAST_VIDEO_IDS_TO_TRACK;
}
};
/**
* Injection point.
*/
public static String spoofParameter(@NonNull String videoId, @Nullable String parameter) {
if (SPOOF_PLAYER_PARAMETER) {
synchronized (lastVideoIds) {
Boolean isSamples = parameterIsSample(parameter);
if (lastVideoIds.put(videoId, isSamples) == null) {
Logger.printDebug(() -> "New video loaded (videoId: " + videoId + ", isSamples: " + isSamples + ")");
}
}
return parameterIsAgeRestricted(parameter)
? PLAYER_PARAMETER_SHORTS
: PLAYER_PARAMETER_SAMPLES;
}
return parameter;
}
/**
* Injection point. Overrides values passed into SubtitleWindowSettings constructor.
*
* @param ap anchor position. A bitmask with 6 bit fields, that appears to indicate the layout position on screen
* @param ah anchor horizontal. A percentage [0, 100], that appears to be a horizontal text anchor point
* @param av anchor vertical. A percentage [0, 100], that appears to be a vertical text anchor point
* @param vs appears to indicate if subtitles exist, and the value is always true.
* @param sd function is not entirely clear
*/
public static int[] fixSubtitleWindowPosition(int ap, int ah, int av, boolean vs, boolean sd) {
// Videos with custom captions that specify screen positions appear to always have correct screen positions (even with spoofing).
// But for auto generated and most other captions, the spoof incorrectly gives various default Samples caption settings.
// Check for these known default Samples captions parameters, and replace with the known correct values.
//
// If a regular video uses a custom subtitle setting that match a default Samples setting,
// then this will incorrectly replace the setting.
// But, if the video uses multiple subtitles in different screen locations, then detect the non-default values
// and do not replace any window settings for the video (regardless if they match a Samples default).
if (SPOOF_PLAYER_PARAMETER &&
numberOfNonDefaultSettingsObserved < NUMBER_OF_NON_DEFAULT_SUBTITLES_BEFORE_ENABLING_PASSTHRU) {
synchronized (lastVideoIds) {
String videoId = VideoInformation.getVideoId();
Boolean isSample = lastVideoIds.get(videoId);
if (BooleanUtils.isFalse(isSample)) {
for (SubtitleWindowReplacementSettings setting : SubtitleWindowReplacementSettings.values()) {
if (setting.match(ap, ah, av, vs, sd)) {
return setting.replacementSetting();
}
}
numberOfNonDefaultSettingsObserved++;
}
}
}
return new int[]{ap, ah, av};
}
/**
* Injection point.
* <p>
* Return false to force disable age restricted playback feature flag.
*/
public static boolean forceDisableAgeRestrictedPlaybackFeatureFlag(boolean original) {
if (SPOOF_PLAYER_PARAMETER) {
return false;
}
return original;
}
/**
* Known incorrect default Samples subtitle parameters, and the corresponding correct (non-Samples) values.
*/
private enum SubtitleWindowReplacementSettings {
DEFAULT_SAMPLES_PARAMETERS_1(10, 50, 0, true, false,
34, 50, 95),
DEFAULT_SAMPLES_PARAMETERS_2(9, 20, 0, true, false,
34, 50, 90),
DEFAULT_SAMPLES_PARAMETERS_3(9, 20, 0, true, true,
33, 20, 100);
// original values
final int ap, ah, av;
final boolean vs, sd;
// replacement int values
final int[] replacement;
SubtitleWindowReplacementSettings(int ap, int ah, int av, boolean vs, boolean sd,
int replacementAp, int replacementAh, int replacementAv) {
this.ap = ap;
this.ah = ah;
this.av = av;
this.vs = vs;
this.sd = sd;
this.replacement = new int[]{replacementAp, replacementAh, replacementAv};
}
boolean match(int ap, int ah, int av, boolean vs, boolean sd) {
return this.ap == ap && this.ah == ah && this.av == av && this.vs == vs && this.sd == sd;
}
int[] replacementSetting() {
return replacement;
}
}
}

View File

@ -190,6 +190,7 @@ public class Settings extends BaseSettings {
public static final BooleanSetting DISABLE_DRC_AUDIO = new BooleanSetting("revanced_disable_drc_audio", FALSE, true);
public static final BooleanSetting DISABLE_MUSIC_VIDEO_IN_ALBUM = new BooleanSetting("revanced_disable_music_video_in_album", FALSE, true);
public static final EnumSetting<RedirectType> DISABLE_MUSIC_VIDEO_IN_ALBUM_REDIRECT_TYPE = new EnumSetting<>("revanced_disable_music_video_in_album_redirect_type", RedirectType.REDIRECT, true);
public static final BooleanSetting SPOOF_PLAYER_PARAMETER = new BooleanSetting("revanced_spoof_player_parameter", TRUE, true);
public static final BooleanSetting SETTINGS_IMPORT_EXPORT = new BooleanSetting("revanced_extended_settings_import_export", FALSE, false);
// PreferenceScreen: Return YouTube Dislike

View File

@ -22,12 +22,25 @@ public final class VideoInformation {
private static final float DEFAULT_YOUTUBE_MUSIC_PLAYBACK_SPEED = 1.0f;
private static final int DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY = -2;
private static final String DEFAULT_YOUTUBE_MUSIC_VIDEO_QUALITY_STRING = getString("quality_auto");
/**
* Prefix present in all Age-restricted music player parameters signature.
*/
private static final String AGE_RESTRICTED_PLAYER_PARAMETER = "ygYQ";
/**
* Prefix present in all Sample player parameters signature.
*/
private static final String SAMPLES_PLAYER_PARAMETERS = "8AEB";
@NonNull
private static String videoId = "";
private static long videoLength = 0;
private static long videoTime = -1;
@NonNull
private static volatile String playerResponseVideoId = "";
private static volatile boolean playerResponseVideoIdIsSample;
/**
* The current playback speed
*/
@ -85,6 +98,65 @@ public final class VideoInformation {
videoId = newlyLoadedVideoId;
}
/**
* Differs from {@link #videoId} as this is the video id for the
* last player response received, which may not be the last video opened.
* <p>
* If Shorts are loading the background, this commonly will be
* different from the Short that is currently on screen.
* <p>
* For most use cases, you should instead use {@link #getVideoId()}.
*
* @return The id of the last video loaded, or an empty string if no videos have been loaded yet.
*/
@NonNull
public static String getPlayerResponseVideoId() {
return playerResponseVideoId;
}
/**
* @return If the last player response video id was a Sample.
*/
public static boolean lastPlayerResponseIsSample() {
return playerResponseVideoIdIsSample;
}
/**
* Injection point. Called off the main thread.
*
* @param videoId The id of the last video loaded.
*/
public static void setPlayerResponseVideoId(@NonNull String videoId) {
if (!playerResponseVideoId.equals(videoId)) {
playerResponseVideoId = videoId;
}
}
/**
* @return If the player parameter is for a Age-restricted video.
*/
public static boolean parameterIsAgeRestricted(@Nullable String parameter) {
return parameter != null && parameter.startsWith(AGE_RESTRICTED_PLAYER_PARAMETER);
}
/**
* @return If the player parameter is for a Sample.
*/
public static boolean parameterIsSample(@Nullable String parameter) {
return parameter != null && parameter.startsWith(SAMPLES_PLAYER_PARAMETERS);
}
/**
* Injection point.
*/
@Nullable
public static String newPlayerResponseParameter(@NonNull String videoId, @Nullable String playerParameter) {
playerResponseVideoIdIsSample = parameterIsSample(playerParameter);
Logger.printDebug(() -> "videoId: " + videoId + ", playerParameter: " + playerParameter);
return playerParameter; // Return the original value since we are observing and not modifying.
}
/**
* Seek on the current video.
* Does not function for playback of Shorts.

View File

@ -14,7 +14,8 @@ import app.revanced.patches.music.utils.settings.addSwitchPreference
import app.revanced.patches.music.utils.settings.settingsPatch
import app.revanced.patches.music.video.information.videoIdHook
import app.revanced.patches.music.video.information.videoInformationPatch
import app.revanced.patches.music.video.playerresponse.hookPlayerResponse
import app.revanced.patches.music.video.playerresponse.Hook
import app.revanced.patches.music.video.playerresponse.addPlayerResponseMethodHook
import app.revanced.patches.music.video.playerresponse.playerResponseMethodHookPatch
import app.revanced.util.findMethodOrThrow
import app.revanced.util.fingerprint.methodOrThrow
@ -46,7 +47,11 @@ val albumMusicVideoPatch = bytecodePatch(
// region hook player response
hookPlayerResponse("$EXTENSION_CLASS_DESCRIPTOR->newPlayerResponse(Ljava/lang/String;Ljava/lang/String;I)V")
addPlayerResponseMethodHook(
Hook.VideoIdAndPlaylistId(
"$EXTENSION_CLASS_DESCRIPTOR->newPlayerResponse(Ljava/lang/String;Ljava/lang/String;I)V"
),
)
// endregion

View File

@ -0,0 +1,26 @@
package app.revanced.patches.music.utils.fix.parameter
import app.revanced.util.fingerprint.legacyFingerprint
import app.revanced.util.or
import com.android.tools.smali.dexlib2.AccessFlags
internal val subtitleWindowFingerprint = legacyFingerprint(
name = "subtitleWindowFingerprint",
returnType = "V",
accessFlags = AccessFlags.PUBLIC or AccessFlags.CONSTRUCTOR,
parameters = listOf("I", "I", "I", "Z", "Z"),
strings = listOf("invalid anchorHorizontalPos: %s"),
)
/**
* If this flag is activated, a playback issue occurs in age-restricted videos.
*/
internal const val AGE_RESTRICTED_PLAYBACK_FEATURE_FLAG = 45651506L
internal val ageRestrictedPlaybackFeatureFlagFingerprint = legacyFingerprint(
name = "ageRestrictedPlaybackFeatureFlagFingerprint",
returnType = "Z",
accessFlags = AccessFlags.PUBLIC or AccessFlags.FINAL,
parameters = emptyList(),
literals = listOf(AGE_RESTRICTED_PLAYBACK_FEATURE_FLAG),
)

View File

@ -0,0 +1,82 @@
package app.revanced.patches.music.utils.fix.parameter
import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patches.music.utils.compatibility.Constants.COMPATIBLE_PACKAGE
import app.revanced.patches.music.utils.extension.Constants.MISC_PATH
import app.revanced.patches.music.utils.patch.PatchList.SPOOF_PLAYER_PARAMETER
import app.revanced.patches.music.utils.settings.CategoryType
import app.revanced.patches.music.utils.settings.ResourceUtils.updatePatchStatus
import app.revanced.patches.music.utils.settings.addSwitchPreference
import app.revanced.patches.music.utils.settings.settingsPatch
import app.revanced.patches.music.video.information.videoInformationPatch
import app.revanced.patches.music.video.playerresponse.Hook
import app.revanced.patches.music.video.playerresponse.addPlayerResponseMethodHook
import app.revanced.patches.music.video.playerresponse.playerResponseMethodHookPatch
import app.revanced.util.fingerprint.injectLiteralInstructionBooleanCall
import app.revanced.util.fingerprint.methodOrThrow
import app.revanced.util.fingerprint.resolvable
private const val EXTENSION_CLASS_DESCRIPTOR =
"$MISC_PATH/SpoofPlayerParameterPatch;"
@Suppress("unused")
val spoofPlayerParameterPatch = bytecodePatch(
SPOOF_PLAYER_PARAMETER.title,
SPOOF_PLAYER_PARAMETER.summary
) {
compatibleWith(COMPATIBLE_PACKAGE)
dependsOn(
settingsPatch,
videoInformationPatch,
playerResponseMethodHookPatch,
)
execute {
addPlayerResponseMethodHook(
Hook.PlayerParameter(
"$EXTENSION_CLASS_DESCRIPTOR->spoofParameter(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"
),
)
// region fix for subtitles position
subtitleWindowFingerprint.methodOrThrow().addInstructions(
0,
"""
invoke-static {p1, p2, p3, p4, p5}, $EXTENSION_CLASS_DESCRIPTOR->fixSubtitleWindowPosition(IIIZZ)[I
move-result-object v0
const/4 v1, 0x0
aget p1, v0, v1 # ap, anchor position
const/4 v1, 0x1
aget p2, v0, v1 # ah, horizontal anchor
const/4 v1, 0x2
aget p3, v0, v1 # av, vertical anchor
"""
)
// endregion
// region fix for feature flags
if (ageRestrictedPlaybackFeatureFlagFingerprint.resolvable()) {
ageRestrictedPlaybackFeatureFlagFingerprint.injectLiteralInstructionBooleanCall(
AGE_RESTRICTED_PLAYBACK_FEATURE_FLAG,
"$EXTENSION_CLASS_DESCRIPTOR->forceDisableAgeRestrictedPlaybackFeatureFlag(Z)Z"
)
}
// endregion
addSwitchPreference(
CategoryType.MISC,
"revanced_spoof_player_parameter",
"true"
)
updatePatchStatus(SPOOF_PLAYER_PARAMETER)
}
}

View File

@ -157,6 +157,10 @@ internal enum class PatchList(
"Spoof client",
"Adds options to spoof the client to allow playback."
),
SPOOF_PLAYER_PARAMETER(
"Spoof player parameter",
"Adds options to spoof player parameter to allow playback."
),
TRANSLATIONS_FOR_YOUTUBE_MUSIC(
"Translations for YouTube Music",
"Add translations or remove string resources."

View File

@ -13,6 +13,9 @@ import app.revanced.patches.music.utils.extension.Constants.SHARED_PATH
import app.revanced.patches.music.utils.playbackSpeedFingerprint
import app.revanced.patches.music.utils.playbackSpeedParentFingerprint
import app.revanced.patches.music.utils.resourceid.sharedResourceIdPatch
import app.revanced.patches.music.video.playerresponse.Hook
import app.revanced.patches.music.video.playerresponse.addPlayerResponseMethodHook
import app.revanced.patches.music.video.playerresponse.playerResponseMethodHookPatch
import app.revanced.patches.shared.mdxPlayerDirectorSetVideoStageFingerprint
import app.revanced.patches.shared.videoLengthFingerprint
import app.revanced.util.addStaticFieldToExtension
@ -71,7 +74,10 @@ private var videoTimeConstructorInsertIndex = 2
val videoInformationPatch = bytecodePatch(
description = "videoInformationPatch",
) {
dependsOn(sharedResourceIdPatch)
dependsOn(
playerResponseMethodHookPatch,
sharedResourceIdPatch
)
execute {
fun addSeekInterfaceMethods(
@ -241,7 +247,18 @@ val videoInformationPatch = bytecodePatch(
* Set current video id
*/
videoIdHook("$EXTENSION_CLASS_DESCRIPTOR->setVideoId(Ljava/lang/String;)V")
addPlayerResponseMethodHook(
Hook.VideoId(
"$EXTENSION_CLASS_DESCRIPTOR->setPlayerResponseVideoId(Ljava/lang/String;)V"
),
)
// Call before any other video id hooks,
// so they can use VideoInformation and check if the video id is for a Short.
addPlayerResponseMethodHook(
Hook.PlayerParameterBeforeVideoId(
"$EXTENSION_CLASS_DESCRIPTOR->newPlayerResponseParameter(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"
)
)
/**
* Hook current playback speed
*/

View File

@ -11,8 +11,7 @@ private val PLAYER_PARAMETER_STARTS_WITH_PARAMETER_LIST = listOf(
"[B",
"Ljava/lang/String;", // Player parameters proto buffer.
"Ljava/lang/String;", // PlaylistId.
"I", // PlaylistIndex.
"I"
"I" // PlaylistIndex.
)
/**
@ -30,7 +29,7 @@ internal val playerParameterBuilderFingerprint = legacyFingerprint(
return@custom false
}
val startsWithMethodParameterList = parameterTypes.slice(0..5)
val startsWithMethodParameterList = parameterTypes.slice(0..4)
parametersEqual(
PLAYER_PARAMETER_STARTS_WITH_PARAMETER_LIST,

View File

@ -1,22 +1,35 @@
package app.revanced.patches.music.video.playerresponse
import app.revanced.patcher.extensions.InstructionExtensions.addInstruction
import app.revanced.patcher.extensions.InstructionExtensions.addInstructions
import app.revanced.patcher.patch.bytecodePatch
import app.revanced.patcher.util.proxy.mutableTypes.MutableMethod
import app.revanced.patches.music.utils.extension.sharedExtensionPatch
import app.revanced.patches.music.utils.playservice.is_7_03_or_greater
import app.revanced.patches.music.utils.playservice.versionCheckPatch
import app.revanced.util.fingerprint.methodOrThrow
private val hooks = mutableSetOf<Hook>()
fun addPlayerResponseMethodHook(hook: Hook) {
hooks += hook
}
private const val REGISTER_VIDEO_ID = "p1"
private const val REGISTER_PLAYER_PARAMETER = "p3"
private const val REGISTER_PLAYLIST_ID = "p4"
private const val REGISTER_PLAYLIST_INDEX = "p5"
private lateinit var playerResponseMethod: MutableMethod
private var numberOfInstructionsAdded = 0
val playerResponseMethodHookPatch = bytecodePatch(
description = "playerResponseMethodHookPatch"
) {
dependsOn(versionCheckPatch)
dependsOn(
sharedExtensionPatch,
versionCheckPatch,
)
execute {
playerResponseMethod = if (is_7_03_or_greater) {
@ -25,16 +38,70 @@ val playerResponseMethodHookPatch = bytecodePatch(
playerParameterBuilderLegacyFingerprint
}.methodOrThrow()
}
finalize {
fun hookVideoId(hook: Hook) {
playerResponseMethod.addInstruction(
0,
"invoke-static {$REGISTER_VIDEO_ID}, $hook",
)
numberOfInstructionsAdded++
}
fun hookVideoIdAndPlaylistId(hook: Hook) {
playerResponseMethod.addInstruction(
0,
"invoke-static {$REGISTER_VIDEO_ID, $REGISTER_PLAYLIST_ID, $REGISTER_PLAYLIST_INDEX}, $hook",
)
numberOfInstructionsAdded++
}
fun hookPlayerParameter(hook: Hook) {
playerResponseMethod.addInstructions(
0,
"""
invoke-static {$REGISTER_VIDEO_ID, v0}, $hook
move-result-object v0
""",
)
numberOfInstructionsAdded += 2
}
// Reverse the order in order to preserve insertion order of the hooks.
val beforeVideoIdHooks =
hooks.filterIsInstance<Hook.PlayerParameterBeforeVideoId>().asReversed()
val videoIdHooks = hooks.filterIsInstance<Hook.VideoId>().asReversed()
val videoIdAndPlaylistIdHooks = hooks.filterIsInstance<Hook.VideoIdAndPlaylistId>().asReversed()
val afterVideoIdHooks = hooks.filterIsInstance<Hook.PlayerParameter>().asReversed()
// Add the hooks in this specific order as they insert instructions at the beginning of the method.
afterVideoIdHooks.forEach(::hookPlayerParameter)
videoIdAndPlaylistIdHooks.forEach(::hookVideoIdAndPlaylistId)
videoIdHooks.forEach(::hookVideoId)
beforeVideoIdHooks.forEach(::hookPlayerParameter)
playerResponseMethod.apply {
addInstruction(
0,
"move-object/from16 v0, $REGISTER_PLAYER_PARAMETER"
)
numberOfInstructionsAdded++
// Move the modified register back.
addInstruction(
numberOfInstructionsAdded,
"move-object/from16 $REGISTER_PLAYER_PARAMETER, v0"
)
}
}
}
fun hookPlayerResponse(
descriptor: String,
onlyVideoId: Boolean = false
) {
val smaliInstruction = if (onlyVideoId)
"invoke-static {$REGISTER_VIDEO_ID}, $descriptor"
else
"invoke-static {$REGISTER_VIDEO_ID, $REGISTER_PLAYLIST_ID, $REGISTER_PLAYLIST_INDEX}, $descriptor"
sealed class Hook(private val methodDescriptor: String) {
class VideoId(methodDescriptor: String) : Hook(methodDescriptor)
class VideoIdAndPlaylistId(methodDescriptor: String) : Hook(methodDescriptor)
playerResponseMethod.addInstruction(0, smaliInstruction)
class PlayerParameter(methodDescriptor: String) : Hook(methodDescriptor)
class PlayerParameterBeforeVideoId(methodDescriptor: String) : Hook(methodDescriptor)
override fun toString() = methodDescriptor
}

View File

@ -501,6 +501,11 @@ Info:
<string name="revanced_spoof_client_type_entry_android_music_5_29">Android Music 5.29.53</string>
<string name="revanced_spoof_client_type_entry_ios_music_6_21">iOS Music 6.21</string>
<string name="revanced_spoof_client_type_entry_ios_music_7_04">iOS Music 7.04</string>
<string name="revanced_spoof_player_parameter_title">Spoof player parameter</string>
<string name="revanced_spoof_player_parameter_summary">"Spoof the player parameter to prevent playback issues.
Side effect:
• Sometimes the subtitles are located at the top of the player instead of the bottom."</string>
<string name="revanced_watch_history_type_title">Watch history type</string>
<string name="revanced_watch_history_type_summary">"• Original: Follows the watch history settings of Google account, but watch history may not work due to DNS or VPN.
• Replace domain: Follows the watch history settings of Google account.