diff --git a/extensions/music/src/main/java/app/morphe/extension/music/patches/EnableSwipeToDismissMiniplayerPatch.java b/extensions/music/src/main/java/app/morphe/extension/music/patches/EnableSwipeToDismissMiniplayerPatch.java new file mode 100644 index 0000000000..1d80d270c6 --- /dev/null +++ b/extensions/music/src/main/java/app/morphe/extension/music/patches/EnableSwipeToDismissMiniplayerPatch.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to this code. + */ + +package app.morphe.extension.music.patches; + +import app.morphe.extension.music.settings.Settings; + +@SuppressWarnings("unused") +public class EnableSwipeToDismissMiniplayerPatch { + + /** + * Injection point + */ + public static boolean enableSwipeToDismissMiniplayer() { + return Settings.ENABLE_SWIPE_TO_DISMISS_MINIPLAYER.get(); + } + + /** + * Injection point + */ + public static Object enableSwipeToDismissMiniplayer(Object object) { + return Settings.ENABLE_SWIPE_TO_DISMISS_MINIPLAYER.get() ? null : object; + } +} \ No newline at end of file diff --git a/extensions/music/src/main/java/app/morphe/extension/music/settings/Settings.java b/extensions/music/src/main/java/app/morphe/extension/music/settings/Settings.java index f92378530e..a7f89cb565 100644 --- a/extensions/music/src/main/java/app/morphe/extension/music/settings/Settings.java +++ b/extensions/music/src/main/java/app/morphe/extension/music/settings/Settings.java @@ -43,6 +43,7 @@ public class Settings extends SharedYouTubeSettings { public static final BooleanSetting MINIPLAYER_PREVIOUS_BUTTON = new BooleanSetting("morphe_music_miniplayer_previous_button", TRUE, true); public static final BooleanSetting CHANGE_MINIPLAYER_COLOR = new BooleanSetting("morphe_music_change_miniplayer_color", FALSE, true); public static final BooleanSetting ENABLE_FORCED_MINIPLAYER = new BooleanSetting("morphe_music_enable_forced_miniplayer", FALSE, true); + public static final BooleanSetting ENABLE_SWIPE_TO_DISMISS_MINIPLAYER = new BooleanSetting("morphe_music_enable_swipe_to_dismiss_miniplayer", FALSE, true); public static final BooleanSetting PERMANENT_REPEAT = new BooleanSetting("morphe_music_play_permanent_repeat", FALSE, true); // Crossfade diff --git a/patches/src/main/kotlin/app/morphe/patches/music/interaction/crossfade/CrossfadePatch.kt b/patches/src/main/kotlin/app/morphe/patches/music/interaction/crossfade/CrossfadePatch.kt index e2b028f97a..7818cbf1ff 100644 --- a/patches/src/main/kotlin/app/morphe/patches/music/interaction/crossfade/CrossfadePatch.kt +++ b/patches/src/main/kotlin/app/morphe/patches/music/interaction/crossfade/CrossfadePatch.kt @@ -162,8 +162,8 @@ val crossfadePatch = bytecodePatch( val log = Logger.getLogger(this::class.java.name) if (!is_8_05_or_greater || is_9_00_or_greater) { return@execute log.warning( - "Track crossfade is not yet available for YouTube Music 9.x. " + - "Patch YouTube Music 8.44.54–8.50.51 for crossfade.", + "Track crossfade is not supported on YouTube Music 9.x. " + + "Please patch versions 8.44.54 through 8.50.51 for this feature.", ) } diff --git a/patches/src/main/kotlin/app/morphe/patches/music/layout/buttons/HideButtons.kt b/patches/src/main/kotlin/app/morphe/patches/music/layout/buttons/HideButtonsPatch.kt similarity index 99% rename from patches/src/main/kotlin/app/morphe/patches/music/layout/buttons/HideButtons.kt rename to patches/src/main/kotlin/app/morphe/patches/music/layout/buttons/HideButtonsPatch.kt index 9e23000163..ac3529a35c 100644 --- a/patches/src/main/kotlin/app/morphe/patches/music/layout/buttons/HideButtons.kt +++ b/patches/src/main/kotlin/app/morphe/patches/music/layout/buttons/HideButtonsPatch.kt @@ -21,7 +21,7 @@ import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction private const val EXTENSION_CLASS = "Lapp/morphe/extension/music/patches/HideButtonsPatch;" @Suppress("unused") -val hideButtons = bytecodePatch( +val hideButtonsPatch = bytecodePatch( name = "Hide buttons", description = "Adds options to hide the cast, history, notification, and search buttons." ) { diff --git a/patches/src/main/kotlin/app/morphe/patches/music/layout/compactheader/HideCategoryBar.kt b/patches/src/main/kotlin/app/morphe/patches/music/layout/compactheader/HideCategoryBarPatch.kt similarity index 97% rename from patches/src/main/kotlin/app/morphe/patches/music/layout/compactheader/HideCategoryBar.kt rename to patches/src/main/kotlin/app/morphe/patches/music/layout/compactheader/HideCategoryBarPatch.kt index 7787ac2947..96346bf0c0 100644 --- a/patches/src/main/kotlin/app/morphe/patches/music/layout/compactheader/HideCategoryBar.kt +++ b/patches/src/main/kotlin/app/morphe/patches/music/layout/compactheader/HideCategoryBarPatch.kt @@ -13,7 +13,7 @@ import com.android.tools.smali.dexlib2.iface.instruction.OneRegisterInstruction private const val EXTENSION_CLASS = "Lapp/morphe/extension/music/patches/HideCategoryBarPatch;" @Suppress("unused") -val hideCategoryBar = bytecodePatch( +val hideCategoryBarPatch = bytecodePatch( name = "Hide category bar", description = "Adds an option to hide the category bar at the top of the homepage." ) { diff --git a/patches/src/main/kotlin/app/morphe/patches/music/layout/miniplayer/ChangeMiniplayerColor.kt b/patches/src/main/kotlin/app/morphe/patches/music/layout/miniplayer/ChangeMiniplayerColorPatch.kt similarity index 98% rename from patches/src/main/kotlin/app/morphe/patches/music/layout/miniplayer/ChangeMiniplayerColor.kt rename to patches/src/main/kotlin/app/morphe/patches/music/layout/miniplayer/ChangeMiniplayerColorPatch.kt index fed5f4b1bd..957b6af186 100644 --- a/patches/src/main/kotlin/app/morphe/patches/music/layout/miniplayer/ChangeMiniplayerColor.kt +++ b/patches/src/main/kotlin/app/morphe/patches/music/layout/miniplayer/ChangeMiniplayerColorPatch.kt @@ -24,7 +24,7 @@ import com.android.tools.smali.dexlib2.iface.reference.MethodReference private const val EXTENSION_CLASS = "Lapp/morphe/extension/music/patches/ChangeMiniplayerColorPatch;" @Suppress("unused") -val changeMiniplayerColor = bytecodePatch( +val changeMiniplayerColorPatch = bytecodePatch( name = "Change miniplayer color", description = "Adds an option to change the miniplayer background color to match the fullscreen player." ) { diff --git a/patches/src/main/kotlin/app/morphe/patches/music/layout/miniplayer/EnableSwipeToDismissMiniplayerPatch.kt b/patches/src/main/kotlin/app/morphe/patches/music/layout/miniplayer/EnableSwipeToDismissMiniplayerPatch.kt new file mode 100644 index 0000000000..77e0c58d3c --- /dev/null +++ b/patches/src/main/kotlin/app/morphe/patches/music/layout/miniplayer/EnableSwipeToDismissMiniplayerPatch.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-patches + * + * See the included NOTICE file for GPLv3 §7(b) and §7(c) terms that apply to this code. + */ + +package app.morphe.patches.music.layout.miniplayer + +import app.morphe.patcher.extensions.InstructionExtensions.addInstructions +import app.morphe.patcher.extensions.InstructionExtensions.getInstruction +import app.morphe.patcher.extensions.InstructionExtensions.replaceInstruction +import app.morphe.patcher.patch.bytecodePatch +import app.morphe.patches.music.misc.extension.sharedExtensionPatch +import app.morphe.patches.music.misc.settings.PreferenceScreen +import app.morphe.patches.music.misc.settings.settingsPatch +import app.morphe.patches.music.shared.Constants.COMPATIBILITY_YOUTUBE_MUSIC +import app.morphe.patches.shared.misc.settings.preference.SwitchPreference +import app.morphe.util.addInstructionsAtControlFlowLabel +import app.morphe.util.findFreeRegister +import app.morphe.util.getReference +import app.morphe.util.indexOfFirstInstructionOrThrow +import app.morphe.util.indexOfFirstInstructionReversedOrThrow +import app.morphe.util.indexOfFirstLiteralInstructionOrThrow +import com.android.tools.smali.dexlib2.Opcode +import com.android.tools.smali.dexlib2.builder.instruction.BuilderInstruction10x +import com.android.tools.smali.dexlib2.iface.instruction.FiveRegisterInstruction +import com.android.tools.smali.dexlib2.iface.instruction.ReferenceInstruction +import com.android.tools.smali.dexlib2.iface.instruction.TwoRegisterInstruction +import com.android.tools.smali.dexlib2.iface.reference.FieldReference +import com.android.tools.smali.dexlib2.iface.reference.MethodReference +import com.android.tools.smali.dexlib2.iface.reference.Reference +import com.android.tools.smali.dexlib2.iface.reference.StringReference + +private const val EXTENSION_CLASS = "Lapp/morphe/extension/music/patches/EnableSwipeToDismissMiniplayerPatch;" + +@Suppress("unused") +val enableSwipeToDismissMiniplayerPatch = bytecodePatch( + name = "Enable swipe to dismiss miniplayer", + description = "Adds an option to enable dismissing the miniplayer by swiping down on it." +) { + dependsOn( + sharedExtensionPatch, + settingsPatch + ) + + compatibleWith(COMPATIBILITY_YOUTUBE_MUSIC) + + execute { + PreferenceScreen.PLAYER.addPreferences( + SwitchPreference("morphe_music_enable_swipe_to_dismiss_miniplayer") + ) + + val swipeToDismissSGetObjectReference = InteractionLoggingEnumFingerprint.method.let { m -> + val stringIndex = m.indexOfFirstInstructionOrThrow { getReference()?.string == "INTERACTION_LOGGING_GESTURE_TYPE_SWIPE" } + val sPutObjectIndex = m.indexOfFirstInstructionOrThrow(stringIndex, Opcode.SPUT_OBJECT) + m.getInstruction(sPutObjectIndex).reference + } + + val musicActivityWidgetMethod = MusicActivityWidgetFingerprint.method + val swipeToDismissWidgetIndex = musicActivityWidgetMethod.indexOfFirstLiteralInstructionOrThrow(79500L) + + fun getSwipeToDismissReference(targetOpcode: Opcode, reversed: Boolean): Reference { + val targetIndex = if (reversed) + musicActivityWidgetMethod.indexOfFirstInstructionReversedOrThrow(swipeToDismissWidgetIndex) { + opcode == targetOpcode + } + else + musicActivityWidgetMethod.indexOfFirstInstructionOrThrow(swipeToDismissWidgetIndex, targetOpcode) + + return musicActivityWidgetMethod.getInstruction(targetIndex).reference + } + + val swipeToDismissIGetObjectReference = getSwipeToDismissReference(Opcode.IGET_OBJECT, true) + val swipeToDismissInvokeInterfacePrimaryReference = getSwipeToDismissReference(Opcode.INVOKE_INTERFACE, true) + val swipeToDismissCheckCastReference = getSwipeToDismissReference(Opcode.CHECK_CAST, true) + val swipeToDismissNewInstanceReference = getSwipeToDismissReference(Opcode.NEW_INSTANCE, true) + val swipeToDismissInvokeStaticReference = getSwipeToDismissReference(Opcode.INVOKE_STATIC, false) + val swipeToDismissInvokeDirectReference = getSwipeToDismissReference(Opcode.INVOKE_DIRECT, false) + val swipeToDismissInvokeInterfaceSecondaryReference = getSwipeToDismissReference(Opcode.INVOKE_INTERFACE, false) + val dismissBehaviorMethodRef = HandleSignInEventFingerprint.method.let { m -> + val returnIndex = m.indexOfFirstInstructionOrThrow(Opcode.RETURN_VOID) + val invokeIndex = m.indexOfFirstInstructionReversedOrThrow(returnIndex, Opcode.INVOKE_VIRTUAL) + + m.getInstruction(invokeIndex).reference as MethodReference + } + + val dismissBehaviorMethod = mutableClassDefBy(dismissBehaviorMethodRef.definingClass).methods.single { + it.name == dismissBehaviorMethodRef.name && it.parameters == dismissBehaviorMethodRef.parameterTypes && it.returnType == dismissBehaviorMethodRef.returnType + } + + dismissBehaviorMethod.apply { + val insertIndex = indexOfFirstInstructionOrThrow { + getReference()?.type == "Ljava/util/concurrent/atomic/AtomicBoolean;" + } + val primaryRegister = getInstruction(insertIndex).registerB + val freeRegister = findFreeRegister(insertIndex, primaryRegister) + val totalRegs = implementation!!.registerCount + val clobberRegs = (0 until totalRegs).filter { it != primaryRegister } + + if (clobberRegs.size < 3) { + throw IllegalStateException("Method lacks sufficient registers for injection (total: ${totalRegs})") + } + + val secondaryRegister = clobberRegs[0] + val tertiaryRegister = clobberRegs[1] + val nullRegister = clobberRegs[2] + + addInstructionsAtControlFlowLabel( + insertIndex, """ + invoke-static {}, $EXTENSION_CLASS->enableSwipeToDismissMiniplayer()Z + move-result v$freeRegister + if-nez v$freeRegister, :dismiss + + # We are safe to aggressively clobber inside here + iget-object v$primaryRegister, v$primaryRegister, $swipeToDismissIGetObjectReference + invoke-interface {v$primaryRegister}, $swipeToDismissInvokeInterfacePrimaryReference + move-result-object v$primaryRegister + check-cast v$primaryRegister, $swipeToDismissCheckCastReference + + sget-object v$secondaryRegister, $swipeToDismissSGetObjectReference + new-instance v$tertiaryRegister, $swipeToDismissNewInstanceReference + + const v$nullRegister, 0x878b + invoke-static {v$nullRegister}, $swipeToDismissInvokeStaticReference + move-result-object v$nullRegister + invoke-direct {v$tertiaryRegister, v$nullRegister}, $swipeToDismissInvokeDirectReference + + const/4 v$nullRegister, 0x0 + invoke-interface {v$primaryRegister, v$secondaryRegister, v$tertiaryRegister, v$nullRegister}, $swipeToDismissInvokeInterfaceSecondaryReference + return-void + + :dismiss + nop + """ + ) + } + + MiniPlayerDefaultTextFingerprint.method.apply { + if (parameters.isEmpty()) { + addInstructions(0, """ + invoke-static {}, $EXTENSION_CLASS->enableSwipeToDismissMiniplayer()Z + move-result v0 + if-eqz v0, :continue_exec + return-void + :continue_exec + """) + } else { + val insertIndex = indexOfFirstInstructionOrThrow(Opcode.IF_NE) + val insertRegister = getInstruction(insertIndex).registerB + + addInstructions(insertIndex, """ + invoke-static {v$insertRegister}, $EXTENSION_CLASS->enableSwipeToDismissMiniplayer(Ljava/lang/Object;)Ljava/lang/Object; + move-result-object v$insertRegister + """) + } + } + + val targetMethod = MiniPlayerDefaultViewVisibilityFingerprint.classDef.methods.first { + it.parameters == listOf("Landroid/view/View;", "I") + } + + targetMethod.apply { + val bottomSheetBehaviorIndex = indexOfFirstInstructionOrThrow { + val reference = getReference() + opcode == Opcode.INVOKE_VIRTUAL && + reference?.definingClass == "Lcom/google/android/material/bottomsheet/BottomSheetBehavior;" && + reference.parameterTypes.firstOrNull() == "Z" + } + + val invokeInstruction = getInstruction(bottomSheetBehaviorIndex) + val invokeReference = (invokeInstruction as ReferenceInstruction).reference as MethodReference + val registerC = invokeInstruction.registerC + val registerD = invokeInstruction.registerD + replaceInstruction(bottomSheetBehaviorIndex, BuilderInstruction10x(Opcode.NOP)) + + addInstructionsAtControlFlowLabel( + bottomSheetBehaviorIndex, """ + invoke-static {}, $EXTENSION_CLASS->enableSwipeToDismissMiniplayer()Z + move-result v$registerD + if-nez v$registerD, :skip_invoke + invoke-virtual {v$registerC, v$registerD}, $invokeReference + :skip_invoke + nop + """ + ) + } + } +} \ No newline at end of file diff --git a/patches/src/main/kotlin/app/morphe/patches/music/layout/miniplayer/Fingerprints.kt b/patches/src/main/kotlin/app/morphe/patches/music/layout/miniplayer/Fingerprints.kt index 28b9b36e5b..6a5cc7808b 100644 --- a/patches/src/main/kotlin/app/morphe/patches/music/layout/miniplayer/Fingerprints.kt +++ b/patches/src/main/kotlin/app/morphe/patches/music/layout/miniplayer/Fingerprints.kt @@ -9,6 +9,7 @@ import app.morphe.patcher.opcode import app.morphe.patcher.string import app.morphe.patches.all.misc.resources.ResourceType import app.morphe.patches.all.misc.resources.resourceLiteral +import app.morphe.util.indexOfFirstLiteralInstruction import com.android.tools.smali.dexlib2.AccessFlags import com.android.tools.smali.dexlib2.Opcode @@ -65,3 +66,48 @@ internal object MppWatchWhileLayoutFingerprint : Fingerprint( returnType = "V", parameters = listOf(), ) + +internal object InteractionLoggingEnumFingerprint : Fingerprint( + returnType = "V", + strings = listOf("INTERACTION_LOGGING_GESTURE_TYPE_SWIPE") +) + +internal object MusicActivityWidgetFingerprint : Fingerprint( + custom = { method, classDef -> + classDef.type.endsWith("/MusicActivity;") && + method.indexOfFirstLiteralInstruction(79500L) >= 0 + } +) + +internal object HandleSearchRenderedFingerprint : Fingerprint( + returnType = "V", + name = "handleSearchRendered" +) + +internal object HandleSignInEventFingerprint : Fingerprint( + classFingerprint = HandleSearchRenderedFingerprint, + returnType = "V", + name = "handleSignInEvent", + filters = listOf(opcode(Opcode.INVOKE_VIRTUAL), opcode(Opcode.RETURN_VOID)) +) + +internal object MiniPlayerDefaultTextFingerprint : Fingerprint( + returnType = "V", + filters = listOf( + resourceLiteral(ResourceType.STRING, "mini_player_default_text") + ) +) + +internal object MiniPlayerDefaultViewVisibilityFingerprint : Fingerprint( + accessFlags = listOf(AccessFlags.PUBLIC, AccessFlags.FINAL), + returnType = "V", + parameters = listOf("Landroid/view/View;", "F"), + filters = listOf( + opcode(Opcode.IGET_OBJECT), + opcode(Opcode.SUB_FLOAT_2ADDR), + opcode(Opcode.SGET_OBJECT), + opcode(Opcode.INVOKE_VIRTUAL) + ), + name = "a", + custom = { _, classDef -> classDef.methods.count() == 3 } +) \ No newline at end of file diff --git a/patches/src/main/resources/addresources/values/music/strings.xml b/patches/src/main/resources/addresources/values/music/strings.xml index b344246f7d..9a701cea4a 100644 --- a/patches/src/main/resources/addresources/values/music/strings.xml +++ b/patches/src/main/resources/addresources/values/music/strings.xml @@ -85,7 +85,7 @@ Feature implementation by VazerOG" Enable permanent repeat - + Hide cast button Hide history button Hide notification button @@ -104,21 +104,26 @@ Feature implementation by VazerOG" Search Subscriptions - + Hide category bar - + Change miniplayer color Matches the miniplayer color to the fullscreen player - + Show next button in miniplayer Show previous button in miniplayer - + Enable forced miniplayer Prevents the player from automatically expanding to fullscreen when a new track starts + + Enable swipe to dismiss miniplayer + Swiping down on miniplayer dismisses it + Swiping down on miniplayer does nothing + Navigation bar Choose which buttons appear in the navigation bar