From ee97a29056cceb8988645a0f338e57b9b060544a Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 23 Sep 2026 11:41:40 +0800 Subject: [PATCH 01/19] feat(glass): add tablet dual-pane host form to the liquid-glass session --- .../amenhancer/module/hook/GlassSession.kt | 26 ++++++ .../module/hook/PhoneGlassRuntime.kt | 47 ++++++++-- .../module/hook/PhoneGlassSession.kt | 91 ++++++++++++------- .../module/hook/TabletDualPaneGlassSession.kt | 67 ++++++++++++++ .../module/hook/TabletGlassChrome.kt | 16 ++++ .../dev/amenhancer/glass/GlassPolicy.kt | 30 +++++- .../dev/amenhancer/glass/GlassGeometryTest.kt | 63 +++++++++++++ 7 files changed, 297 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/dev/amenhancer/module/hook/GlassSession.kt create mode 100644 app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt create mode 100644 app/src/main/java/dev/amenhancer/module/hook/TabletGlassChrome.kt create mode 100644 glass/src/test/kotlin/dev/amenhancer/glass/GlassGeometryTest.kt diff --git a/app/src/main/java/dev/amenhancer/module/hook/GlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/GlassSession.kt new file mode 100644 index 00000000..6bfe7e96 --- /dev/null +++ b/app/src/main/java/dev/amenhancer/module/hook/GlassSession.kt @@ -0,0 +1,26 @@ +package dev.amenhancer.module.hook + +import android.view.MotionEvent +import android.widget.FrameLayout + +/** + * Dispatch surface of one glass session, consumed by [PhoneGlassRuntime]'s shared hooks. + * [PhoneGlassSession] implements it for the stacked phone host and + * [TabletDualPaneGlassSession] extends that implementation for the dual-pane tablet host. + */ +internal interface GlassSession : AutoCloseable { + val miniRoot: FrameLayout? + val playerBehavior: Any? + val activated: Boolean + + fun attachAvailableViews() + fun ownsCurrentHierarchy(): Boolean + fun onSlide(progress: Float) + fun observeNativePeek(height: Int) + fun peekHeight(): Int + fun redirectedPadding(view: Any?): Int? + fun redirectedLayerAlpha(view: Any?, alpha: Float): Float? + fun observeTouch(event: MotionEvent) + fun foreground(active: Boolean) + override fun close() +} diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt index 21667a16..a84ad82e 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt @@ -9,6 +9,7 @@ import android.view.MotionEvent import android.view.View import android.view.ViewGroup import androidx.annotation.RequiresApi +import dev.amenhancer.glass.GlassHostForm import dev.amenhancer.glass.GlassPolicy import dev.amenhancer.module.ModuleConstants import dev.amenhancer.module.config.TargetConfigClient @@ -19,7 +20,7 @@ import java.util.WeakHashMap @RequiresApi(33) internal object PhoneGlassRuntime { - private val sessions = WeakHashMap() + private val sessions = WeakHashMap() private val failed = java.util.Collections.newSetFromMap(WeakHashMap()) private var applicationRegistered = false private var hooksInstalled = false @@ -36,7 +37,8 @@ internal object PhoneGlassRuntime { val activity = activity(view.context) ?: return if (activity in failed || activity.isFinishing || activity.isDestroyed) return val build = targetBuild(activity) - if (!GlassPolicy.supports(android.os.Build.VERSION.SDK_INT, build.versionCode, build.versionName, TabletModeQualifier.isOfficialTablet(activity))) return + // Both host forms share one seam whitelist; the form itself is routed below. + if (GlassHostForm.values().none { GlassPolicy.supports(android.os.Build.VERSION.SDK_INT, build.versionCode, build.versionName, it) }) return if (!config.settings().phoneLiquidGlassEnabled) return registerLifecycle(activity.application) view.post { @@ -44,12 +46,33 @@ internal object PhoneGlassRuntime { try { installHooks(activity.classLoader) sessions[activity]?.takeUnless { it.ownsCurrentHierarchy() }?.let { it.close(); sessions.remove(activity) } - val session = sessions[activity] ?: PhoneGlassSession(activity, config) { error -> fail(activity, config, error) }.also { sessions[activity] = it } + val desired = createSession(activity, config) { error -> fail(activity, config, error) } + if (desired == null) { sessions.remove(activity)?.close(); return@post } + val session = sessions[activity]?.takeIf { it.javaClass == desired.javaClass } + ?: desired.also { sessions.remove(activity)?.close(); sessions[activity] = it } session.attachAvailableViews() } catch (error: Throwable) { fail(activity, config, error) } } } + /** Routes the host form; null means no session may exist for this activity right now. */ + private fun createSession(activity: Activity, config: TargetConfigClient, onFail: (Throwable) -> Unit): GlassSession? { + val build = targetBuild(activity) + if (config.settings().phoneLiquidGlassEnabled && + !TabletModeQualifier.isOfficialTablet(activity) && + GlassPolicy.supports(android.os.Build.VERSION.SDK_INT, build.versionCode, build.versionName, GlassHostForm.PhoneStacked) + ) { + return PhoneGlassSession(activity, config, onFail) + } + if (config.settings().phoneLiquidGlassEnabled && + TabletModeQualifier.isEligible(activity) && + GlassPolicy.supports(android.os.Build.VERSION.SDK_INT, build.versionCode, build.versionName, GlassHostForm.TabletDualPane) + ) { + return TabletDualPaneGlassSession(activity, config, onFail) + } + return null + } + private fun fail(activity: Activity, config: TargetConfigClient, error: Throwable) { failed += activity sessions.remove(activity)?.close() @@ -62,13 +85,17 @@ internal object PhoneGlassRuntime { if (hooksInstalled) return check(!hooksAttempted) { "Glass hook installation previously failed; restart the host to retry" } hooksAttempted = true - val holder = loader.loadClass("com.apple.android.music.common.activity.PlayerActivity\$StackedBottomNavigationHolder") - ModernXposedRuntime.hookMethod(holder.getDeclaredMethod("c", Float::class.javaPrimitiveType), object : ModernMethodHook() { - override fun afterHookedMethod(param: MethodHookParam) { - val owner = param.thisObject?.let(::outerActivity) ?: return - sessions[owner]?.onSlide((param.args[0] as Number).toFloat()) - } - }) + // The stacked and flat holders each drive their own slide contract; the outer + // activity reflection resolves both holder shapes. + for (holderName in listOf("StackedBottomNavigationHolder", "FlatBottomNavigationHolder")) { + val holder = loader.loadClass("com.apple.android.music.common.activity.PlayerActivity\$$holderName") + ModernXposedRuntime.hookMethod(holder.getDeclaredMethod("c", Float::class.javaPrimitiveType), object : ModernMethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + val owner = param.thisObject?.let(::outerActivity) ?: return + sessions[owner]?.onSlide((param.args[0] as Number).toFloat()) + } + }) + } ModernXposedRuntime.hookMethod(ViewGroup::class.java.getDeclaredMethod("dispatchTouchEvent", MotionEvent::class.java), object : ModernMethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { val root = param.thisObject as? View ?: return diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt index 5926fc39..194e5920 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.kyant.backdrop.backdrops.ViewBackdrop import dev.amenhancer.glass.BottomScrim +import dev.amenhancer.glass.GlassGeometry import dev.amenhancer.glass.GlassHostView import dev.amenhancer.glass.GlassNavigation import dev.amenhancer.glass.GlassPolicy @@ -47,30 +48,31 @@ import kotlin.math.abs import kotlin.math.roundToInt @RequiresApi(33) -internal class PhoneGlassSession( - private val activity: Activity, - private val config: TargetConfigClient, +internal open class PhoneGlassSession( + protected val activity: Activity, + protected val config: TargetConfigClient, private val failure: (Throwable) -> Unit, -) : AutoCloseable, ViewTreeObserver.OnPreDrawListener { +) : GlassSession, ViewTreeObserver.OnPreDrawListener { private val states = IdentityHashMap() private val layerAlphas = IdentityHashMap() private var writingLayerAlpha = false - private var navFrame: FrameLayout? = null + protected var navFrame: FrameLayout? = null private var navigation: View? = null private var source: ViewGroup? = null private var backdrop: ViewBackdrop? = null private var navGlass: GlassHostView? = null private var navScrim: GlassHostView? = null private var miniGlass: GlassHostView? = null - var miniRoot: FrameLayout? = null + final override var miniRoot: FrameLayout? = null private set private var miniContent: View? = null - var playerBehavior: Any? = null + final override var playerBehavior: Any? = null private set private var observer: ViewTreeObserver? = null private var closed = false private var failureScheduled = false - var activated = false + private var hostRoot: View? = null + final override var activated = false private set private var tabs by mutableStateOf(emptyList()) private var selectedId by mutableIntStateOf(View.NO_ID) @@ -101,15 +103,18 @@ internal class PhoneGlassSession( private var scrollTargets: List = emptyList() private val layoutListener = ViewTreeObserver.OnGlobalLayoutListener { scanNeeded = true } private var nextSettingsCheck = 0L - private val density get() = activity.resources.displayMetrics.density + protected val density get() = activity.resources.displayMetrics.density private fun dp(value: Int) = (value * density).roundToInt() - private val bottomInset get() = activity.window.decorView.rootWindowInsets?.getInsets(WindowInsets.Type.navigationBars())?.bottom ?: 0 - private val miniVisible get() = miniRoot?.isShown == true + protected val bottomInset get() = activity.window.decorView.rootWindowInsets?.getInsets(WindowInsets.Type.navigationBars())?.bottom ?: 0 + protected val miniVisible get() = miniRoot?.isShown == true // AM++: user-adjustable glass lift/material, captured with the session so every // height consumer (frame, content padding, peek) agrees within a frame. - private var bottomGapDp = GlassPolicy.BOTTOM_DP + protected var bottomGapDp = GlassPolicy.BOTTOM_DP private var navBlurDp = GlassPolicy.PANEL_BLUR_DP.toInt() + /** Capsule geometry shared by every occupied-height consumer; a diverging form overrides this. */ + protected open val geometry: GlassGeometry get() = GlassGeometry.Phone + // Resource IDs are stable for this Activity's host APK. Keep values and Views live so // configuration changes and replaced page/player hierarchies still take effect. private val resourceIds = HashMap() @@ -122,10 +127,10 @@ internal class PhoneGlassSession( return id } - private fun find(name: String): View? = resourceId(name, "id") + protected fun find(name: String): View? = resourceId(name, "id") .takeIf { it != 0 }?.let { activity.findViewById(it) } - private fun dimen(name: String): Int = resourceId(name, "dimen") + protected fun dimen(name: String): Int = resourceId(name, "dimen") .takeIf { it != 0 }?.let { activity.resources.getDimensionPixelSize(it) } ?: 0 private fun save(view: View): NativeViewState = states.getOrPut(view) { NativeViewState(view) } @@ -140,9 +145,29 @@ internal class PhoneGlassSession( } } - fun attachAvailableViews() { + // Form seams overridden by the dual-pane session; the phone behavior below stays + // exactly what shipped on the stacked host. + protected open fun sessionEligible(): Boolean = + config.settings().phoneLiquidGlassEnabled && !TabletModeQualifier.isOfficialTablet(activity) + + protected open fun resolveBottomNavigationRoot(): View? = find("bottom_navigation_root_stacked") + + // The stacked native holder reserves miniplayer_height even when mini is hidden, + // on top of the tabs height and the bottom inset. + protected open fun nativePeekBaseline(): Int = + bottomInset + dimen("navigation_tabs_height") + dimen("miniplayer_height") + + /** Capsule exit driver; the phone host translates the frame from its own holder. */ + protected open fun driveNavFrameExit(progress: Float) = Unit + + /** Chrome ownership hand-off; only the dual-pane session arbitrates ownership. */ + protected open fun onGlassOwnership(root: View?) = Unit + + protected open fun releaseGlassOwnership(root: View?) = Unit + + override fun attachAvailableViews() { if (closed || failureScheduled) return - if (!config.settings().phoneLiquidGlassEnabled || TabletModeQualifier.isOfficialTablet(activity)) { + if (!sessionEligible()) { close() return } @@ -150,7 +175,7 @@ internal class PhoneGlassSession( val glassSettings = config.settings() bottomGapDp = ModuleSettings.normalizePhoneLiquidGlassBottomGapDp(glassSettings.phoneLiquidGlassBottomGapDp) navBlurDp = ModuleSettings.normalizePhoneLiquidGlassPanelBlurDp(glassSettings.phoneLiquidGlassPanelBlurDp) - if (find("bottom_navigation_root_stacked") == null) return + hostRoot = resolveBottomNavigationRoot() ?: return val frame = find("bottom_navigation_tabs_frame") as? FrameLayout ?: return val nav = find("bottom_navigation") ?: return val content = find("navigation_host_group") as? ViewGroup ?: return @@ -215,7 +240,7 @@ internal class PhoneGlassSession( } } - fun ownsCurrentHierarchy(): Boolean = !closed && (navFrame == null || find("bottom_navigation_tabs_frame") === navFrame) + override fun ownsCurrentHierarchy(): Boolean = !closed && (navFrame == null || find("bottom_navigation_tabs_frame") === navFrame) @androidx.compose.runtime.Composable private fun HostConfiguration(content: @androidx.compose.runtime.Composable () -> Unit) { @@ -284,7 +309,7 @@ internal class PhoneGlassSession( val now = android.os.SystemClock.uptimeMillis() if (now >= nextSettingsCheck) { nextSettingsCheck = now + 500 - if (!config.settings().phoneLiquidGlassEnabled || TabletModeQualifier.isOfficialTablet(activity)) { + if (!sessionEligible()) { activity.window.decorView.post { close() } return true } @@ -335,8 +360,7 @@ internal class PhoneGlassSession( allowGlassOverflow(frame) find("navigation_tabs_divider")?.let { save(it); it.visibility = View.GONE } source?.let { save(it) } - // The stacked native holder reserves miniplayer_height even when mini is hidden. - nativePeek.initialize(bottomInset + dimen("navigation_tabs_height") + dimen("miniplayer_height")) + nativePeek.initialize(nativePeekBaseline()) activated = true prepareMini() navGlass?.alpha = 1f @@ -344,6 +368,8 @@ internal class PhoneGlassSession( updateGeometry() updateUnderlap() updateTransition() + // The dual-pane boundary sync yields geometry ownership once activation completes. + onGlassOwnership(hostRoot) config.reportHealth(FeatureHealth(ModuleConstants.FEATURE_PHONE_LIQUID_GLASS, FeatureState.ACTIVE, "AndroidLiquidGlass 已挂载:实时背景、底栏透镜及迷你播放器;真机视觉验收另行记录", targetBuild(activity).displayName)) } @@ -387,9 +413,9 @@ internal class PhoneGlassSession( } } - fun peekHeight(): Int = GlassPolicy.occupiedHeight(density, bottomInset, miniVisible, bottomGapDp) + if (miniVisible) dimen("shadow_height") else 0 + override fun peekHeight(): Int = GlassPolicy.occupiedHeight(density, bottomInset, miniVisible, bottomGapDp, geometry) + if (miniVisible) dimen("shadow_height") else 0 - fun observeNativePeek(height: Int) = nativePeek.observe(height) + override fun observeNativePeek(height: Int) = nativePeek.observe(height) private fun writePeek(height: Int) = nativePeek.writeByModule { playerBehavior?.let { PhoneGlassRuntime.method(it.javaClass, "F", Int::class.javaPrimitiveType!!, Boolean::class.javaPrimitiveType!!).invoke(it, height, false) } @@ -443,7 +469,7 @@ internal class PhoneGlassSession( val composeScene = descendants(root).any { view -> view.isShown && view.height > 0 && view.javaClass.name == "androidx.compose.ui.platform.ComposeView" } - val occupied = if (navFrame?.isShown == true) GlassPolicy.occupiedHeight(density, bottomInset, miniVisible, bottomGapDp) else 0 + val occupied = if (navFrame?.isShown == true) GlassPolicy.occupiedHeight(density, bottomInset, miniVisible, bottomGapDp, geometry) else 0 if (terminal.isEmpty() && !composeScene) { underlap = false if (root.paddingBottom != occupied) root.setPadding(root.paddingLeft, root.paddingTop, root.paddingRight, occupied) @@ -468,6 +494,7 @@ internal class PhoneGlassSession( navFrame?.let { if (it.outlineProvider != null) it.outlineProvider = null } miniRoot?.background = null val progress = slide.coerceIn(0f, 1f) + driveNavFrameExit(progress) fun blend(start: Float, end: Float): Float { val t = ((progress - start) / (end - start)).coerceIn(0f, 1f) return t * t * (3f - 2f * t) @@ -512,9 +539,9 @@ internal class PhoneGlassSession( find("player_root")?.background = if (materialProgress < 1f) null else states[find("player_root")]?.background } - fun onSlide(progress: Float) { slide = progress.coerceIn(0f, 1f) } + override fun onSlide(progress: Float) { slide = progress.coerceIn(0f, 1f) } - fun redirectedLayerAlpha(view: Any?, alpha: Float): Float? { + override fun redirectedLayerAlpha(view: Any?, alpha: Float): Float? { if (closed || writingLayerAlpha) return null return layerAlphas[view]?.hostWrite(alpha) } @@ -529,8 +556,8 @@ internal class PhoneGlassSession( } } - fun redirectedPadding(view: Any?): Int? = if (activated && view === source) { - if (underlap) 0 else if (navFrame?.isShown == true) GlassPolicy.occupiedHeight(density, bottomInset, miniVisible, bottomGapDp) else 0 + override fun redirectedPadding(view: Any?): Int? = if (activated && view === source) { + if (underlap) 0 else if (navFrame?.isShown == true) GlassPolicy.occupiedHeight(density, bottomInset, miniVisible, bottomGapDp, geometry) else 0 } else null private fun miniGlassPosition(event: MotionEvent): Pair? { @@ -544,7 +571,7 @@ internal class PhoneGlassSession( ) } - fun observeTouch(event: MotionEvent) { + override fun observeTouch(event: MotionEvent) { if (!activated) return val position = miniGlassPosition(event) ?: return when (event.actionMasked) { @@ -558,15 +585,16 @@ internal class PhoneGlassSession( if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) observingPress = false } - fun foreground(active: Boolean) { navGlass?.foreground(active); navScrim?.foreground(active); miniGlass?.foreground(active) } + override fun foreground(active: Boolean) { navGlass?.foreground(active); navScrim?.foreground(active); miniGlass?.foreground(active) } - private fun findPlayerBehavior(): Any? = generateSequence(activity.javaClass as Class<*>?) { it.superclass }.flatMap { it.declaredFields.asSequence() }.firstNotNullOfOrNull { + protected open fun findPlayerBehavior(): Any? = generateSequence(activity.javaClass as Class<*>?) { it.superclass }.flatMap { it.declaredFields.asSequence() }.firstNotNullOfOrNull { if (it.type.name.contains("BottomSheetBehavior")) runCatching { it.isAccessible = true; it.get(activity) }.getOrNull() else null } private fun scheduleFailure(error: Throwable) { if (failureScheduled || closed) return failureScheduled = true + releaseGlassOwnership(hostRoot) activity.window.decorView.post { failure(error) } } @@ -585,6 +613,7 @@ internal class PhoneGlassSession( layerAlphas.forEach { (view, state) -> view.alpha = state.native } layerAlphas.clear() nativePeek.latest?.let { runCatching { writePeek(it) } } + releaseGlassOwnership(hostRoot) activity.window.decorView.requestLayout() } diff --git a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt new file mode 100644 index 00000000..ab15c2bf --- /dev/null +++ b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt @@ -0,0 +1,67 @@ +package dev.amenhancer.module.hook + +import android.app.Activity +import android.view.View +import androidx.annotation.RequiresApi +import dev.amenhancer.glass.GlassGeometry +import dev.amenhancer.glass.GlassPolicy +import dev.amenhancer.module.config.TargetConfigClient +import kotlin.math.exp + +/** + * Dual-pane (flat) tablet form of the liquid-glass session. The phone pipeline is reused + * as-is; only the host-form seams differ: the flat root name, the flat native peek + * baseline, the session-driven capsule exit, chrome ownership arbitration and the + * tightened behavior lookup. + */ +@RequiresApi(33) +internal class TabletDualPaneGlassSession( + activity: Activity, + config: TargetConfigClient, + failure: (Throwable) -> Unit, +) : PhoneGlassSession(activity, config, failure) { + + override val geometry: GlassGeometry get() = GlassGeometry.Tablet + + // The session lives only while the official tablet runs the dual-pane player; + // portrait or dual-pane-off restores the native chrome through close(). + override fun sessionEligible(): Boolean = + config.settings().phoneLiquidGlassEnabled && TabletModeQualifier.isEligible(activity) + + // Flat layout resolves bottom_navigation_root_flat; stacked stays the fallback. + override fun resolveBottomNavigationRoot(): View? = + find("bottom_navigation_root_flat") ?: find("bottom_navigation_root_stacked") + + // The flat holder reserves miniplayer_height only (no navigation_tabs_height). + override fun nativePeekBaseline(): Int = bottomInset + dimen("miniplayer_height") + + // The flat holder never translates the tabs frame, so the capsule exit is driven here + // with the phone StackedBottomNavigationHolder.c exp(-20t) curve over the whole glass + // occupied height (navigation capsule + mini); slide back to 0 parks the capsule again. + override fun driveNavFrameExit(progress: Float) { + val frame = navFrame ?: return + val extent = GlassPolicy.occupiedHeight(density, bottomInset, miniVisible, bottomGapDp, geometry) + frame.translationY = (1f - exp(-20f * progress)) * extent + } + + // The dual-pane boundary sync mutes its own writes while the glass owns the geometry. + override fun onGlassOwnership(root: View?) { + root?.let(TabletGlassChrome::markGlassActive) + } + + override fun releaseGlassOwnership(root: View?) { + root?.let(TabletGlassChrome::clearGlassActive) + } + + // The host field declares BottomSheetBehavior but runs + // PlayerBottomSheetBehavior (and is the activity's only Behavior field). Prefer a + // value whose runtime class names it; fall back to the phone declared-type scan. + override fun findPlayerBehavior(): Any? { + generateSequence(activity.javaClass as Class<*>?) { it.superclass } + .flatMap { it.declaredFields.asSequence() } + .mapNotNull { field -> runCatching { field.isAccessible = true; field.get(activity) }.getOrNull() } + .firstOrNull { it.javaClass.name.contains("PlayerBottomSheetBehavior") } + ?.let { return it } + return super.findPlayerBehavior() + } +} diff --git a/app/src/main/java/dev/amenhancer/module/hook/TabletGlassChrome.kt b/app/src/main/java/dev/amenhancer/module/hook/TabletGlassChrome.kt new file mode 100644 index 00000000..c0a060a5 --- /dev/null +++ b/app/src/main/java/dev/amenhancer/module/hook/TabletGlassChrome.kt @@ -0,0 +1,16 @@ +package dev.amenhancer.module.hook + +import android.view.View + +/** Single-writer arbitration between the dual-pane flat boundary sync and the tablet glass session. */ +internal object TabletGlassChrome { + private val activeRoots: MutableSet = + java.util.Collections.newSetFromMap(java.util.WeakHashMap()) + + @JvmStatic fun markGlassActive(root: View) { synchronized(activeRoots) { activeRoots.add(root) } } + + @JvmStatic fun clearGlassActive(root: View) { synchronized(activeRoots) { activeRoots.remove(root) } } + + @JvmStatic fun isGlassActive(root: View): Boolean = + synchronized(activeRoots) { activeRoots.contains(root) } +} diff --git a/glass/src/main/kotlin/dev/amenhancer/glass/GlassPolicy.kt b/glass/src/main/kotlin/dev/amenhancer/glass/GlassPolicy.kt index 69c9a1be..1b2571cc 100644 --- a/glass/src/main/kotlin/dev/amenhancer/glass/GlassPolicy.kt +++ b/glass/src/main/kotlin/dev/amenhancer/glass/GlassPolicy.kt @@ -1,5 +1,26 @@ package dev.amenhancer.glass +/** Host layout form carrying the verified liquid-glass seams. Both forms share one build whitelist. */ +enum class GlassHostForm { PhoneStacked, TabletDualPane } + +/** + * Capsule geometry per host form. Defaults match the accepted phone capsule. + * Fork reference for a future tablet variant: sw640dp ships native + * miniplayer_height=59dp / mini_player_thumbnail_height=41dp (phone 67/64, 48). + */ +data class GlassGeometry( + val navHeightDp: Int = 56, + val miniHeightDp: Int = 43, + val horizontalDp: Int = 16, + val gapDp: Int = 8, +) { + companion object { + val Phone = GlassGeometry() + /** Starts identical to [Phone]; diverge here when tablet geometry is verified. */ + val Tablet = GlassGeometry() + } +} + /** Android-free invariants used by both the host bridge and regression tests. */ object GlassPolicy { /** Host builds whose phone layout carries the verified liquid-glass seams. */ @@ -27,7 +48,11 @@ object GlassPolicy { const val PANEL_BLUR_DP = 4f fun supports(sdk: Int, versionCode: Long, versionName: String, tablet: Boolean) = - sdk >= 33 && !tablet && isSupportedBuild(versionCode, versionName) + !tablet && supports(sdk, versionCode, versionName, GlassHostForm.PhoneStacked) + + /** Both host forms share the same verified seam whitelist per build. */ + fun supports(sdk: Int, versionCode: Long, versionName: String, form: GlassHostForm): Boolean = + sdk >= 33 && isSupportedBuild(versionCode, versionName) fun isSupportedBuild(versionCode: Long, versionName: String): Boolean = SUPPORTED_BUILDS.any { supported -> @@ -42,6 +67,7 @@ object GlassPolicy { bottomInset: Int, miniVisible: Boolean, bottomGapDp: Int = BOTTOM_DP, + geometry: GlassGeometry = GlassGeometry.Phone, ): Int = - ((NAV_HEIGHT_DP + bottomGapDp + if (miniVisible) MINI_HEIGHT_DP + GAP_DP else 0) * density).toInt() + bottomInset + ((geometry.navHeightDp + bottomGapDp + if (miniVisible) geometry.miniHeightDp + geometry.gapDp else 0) * density).toInt() + bottomInset } diff --git a/glass/src/test/kotlin/dev/amenhancer/glass/GlassGeometryTest.kt b/glass/src/test/kotlin/dev/amenhancer/glass/GlassGeometryTest.kt new file mode 100644 index 00000000..e42b84cb --- /dev/null +++ b/glass/src/test/kotlin/dev/amenhancer/glass/GlassGeometryTest.kt @@ -0,0 +1,63 @@ +package dev.amenhancer.glass + +import org.junit.Assert.* +import org.junit.Test + +class GlassGeometryTest { + @Test fun phoneAndTabletPresetsStartIdentical() { + // sw640dp native miniplayer_height=59dp / mini_player_thumbnail_height=41dp are the + // documented fork reference; both presets deliberately share the phone capsule today. + assertEquals(GlassGeometry.Phone, GlassGeometry.Tablet) + assertEquals( + GlassGeometry(navHeightDp = 56, miniHeightDp = 43, horizontalDp = 16, gapDp = 8), + GlassGeometry.Phone, + ) + assertEquals(GlassPolicy.NAV_HEIGHT_DP, GlassGeometry.Phone.navHeightDp) + assertEquals(GlassPolicy.MINI_HEIGHT_DP, GlassGeometry.Phone.miniHeightDp) + assertEquals(GlassPolicy.HORIZONTAL_DP, GlassGeometry.Phone.horizontalDp) + assertEquals(GlassPolicy.GAP_DP, GlassGeometry.Phone.gapDp) + } + + @Test fun occupiedHeightDefaultsToPhoneGeometry() { + assertEquals( + GlassPolicy.occupiedHeight(2f, 24, true), + GlassPolicy.occupiedHeight(2f, 24, true, GlassPolicy.BOTTOM_DP, GlassGeometry.Phone), + ) + assertEquals( + GlassPolicy.occupiedHeight(1f, 0, false), + GlassPolicy.occupiedHeight(1f, 0, false, geometry = GlassGeometry.Phone), + ) + } + + @Test fun occupiedHeightFollowsAForkedGeometry() { + val forked = GlassGeometry(navHeightDp = 64, miniHeightDp = 51, horizontalDp = 20, gapDp = 10) + // (64 + 16 + 51 + 10) * 2 + 24 = 306 + assertEquals(306, GlassPolicy.occupiedHeight(2f, 24, true, geometry = forked)) + // (64 + 16) * 2 + 24 = 184 + assertEquals(184, GlassPolicy.occupiedHeight(2f, 24, false, geometry = forked)) + // geometry composes with a custom bottom lift: (64 + 24 + 51 + 10) * 1 + 8 = 157 + assertEquals(157, GlassPolicy.occupiedHeight(1f, 8, true, bottomGapDp = 24, geometry = forked)) + } + + @Test fun hostFormsShareOneSeamWhitelist() { + assertTrue(GlassPolicy.supports(33, 1586, "6.5.2", GlassHostForm.PhoneStacked)) + assertTrue(GlassPolicy.supports(33, 1586, "6.5.2", GlassHostForm.TabletDualPane)) + assertTrue(GlassPolicy.supports(36, 1599, "6.5.3", GlassHostForm.TabletDualPane)) + assertFalse(GlassPolicy.supports(32, 1586, "6.5.2", GlassHostForm.TabletDualPane)) + assertFalse(GlassPolicy.supports(32, 1599, "6.5.3", GlassHostForm.PhoneStacked)) + assertFalse(GlassPolicy.supports(36, 1583, "6.5.1", GlassHostForm.PhoneStacked)) + assertFalse(GlassPolicy.supports(36, 1587, "6.5.2", GlassHostForm.TabletDualPane)) + assertFalse(GlassPolicy.supports(36, 1599, "6.5.4", GlassHostForm.TabletDualPane)) + } + + @Test fun legacyTabletFlagKeepsPhoneOnlySemantics() { + // phone = !tablet: the legacy overload keeps rejecting tablets while delegating + // the build check to the form overload. + assertTrue(GlassPolicy.supports(33, 1586, "6.5.2", false)) + assertTrue(GlassPolicy.supports(36, 1599, "6.5.3", false)) + assertFalse(GlassPolicy.supports(33, 1586, "6.5.2", true)) + assertFalse(GlassPolicy.supports(36, 1599, "6.5.3", true)) + assertFalse(GlassPolicy.supports(32, 1586, "6.5.2", false)) + assertFalse(GlassPolicy.supports(36, 1587, "6.5.2", false)) + } +} From a7a17bc45c17a316f241efdaea90f5be08614f9a Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 23 Sep 2026 11:41:40 +0800 Subject: [PATCH 02/19] feat(dual-pane): silence flat boundary sync while tablet glass owns geometry --- .../module/hook/AppleMusicDualPaneTarget.kt | 10 ++++++++++ .../hook/DualPaneStructuralRegressionTest.kt | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt index dac41863..059641ec 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt @@ -1631,6 +1631,16 @@ private object ConstraintLayoutPane { val sheetLocation = IntArray(2) val tabsLocation = IntArray(2) fun sync() { + // Geometry arbitration: while the liquid-glass session is active + // its rewritten peek height is the single source of collapsed + // geometry, so this controller must stay silent — writing + // translationY/tabs visibility on top of the glass peek would + // double-lift the mini player out of the capsule position and + // expose a black strip. The listener and the reserveNavigationSpace + // latch semantics are preserved; once glass clears (switch off, + // predicate false, fail-closed recovery) the compare-then-write + // mechanism below re-asserts the settled values byte-identically. + if (TabletGlassChrome.isGlassActive(root)) return val rootHeight = root.height if (rootHeight <= 0) return root.getLocationInWindow(rootLocation) diff --git a/app/src/test/java/dev/amenhancer/module/hook/DualPaneStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/DualPaneStructuralRegressionTest.kt index 55c4a938..93d8e3f6 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/DualPaneStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/DualPaneStructuralRegressionTest.kt @@ -271,6 +271,24 @@ class DualPaneStructuralRegressionTest { assertTrue(source.contains("flat boundary sync skipped: no player_sheet_container")) } + @Test + fun `silences flat boundary sync writes while the tablet glass session is active`() { + // Geometry arbitration: the liquid-glass peek rewrite is the single + // source of collapsed geometry while glass is active, so sync() must + // bail out before touching playerContainer.translationY or + // tabsFrame.visibility (a peek+translation double lift would push the + // mini player out of its capsule and expose a black strip). + assertTrue(source.contains("TabletGlassChrome.isGlassActive(root)")) + val syncIndex = source.indexOf("fun sync() {") + assertTrue(syncIndex >= 0) + val guardIndex = source.indexOf("TabletGlassChrome.isGlassActive(root)", syncIndex) + assertTrue(guardIndex > syncIndex) + val translationWriteIndex = source.indexOf("playerContainer.translationY = desiredTranslation", syncIndex) + val tabsVisibilityWriteIndex = source.indexOf("tabsFrame.visibility = desiredTabsVisibility", syncIndex) + assertTrue(translationWriteIndex > guardIndex) + assertTrue(tabsVisibilityWriteIndex > guardIndex) + } + @Test fun `creates the right lyrics pane through the target controller factory`() { assertFalse(source.contains("lyricsClass.getDeclaredConstructor()")) From fe9aec5b0b6bd0fe6f70e816acbd27c06ae62e0f Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 23 Sep 2026 11:41:40 +0800 Subject: [PATCH 03/19] feat(settings): scope liquid-glass copy to phone and tablet dual-pane --- README.md | 22 ++--- .../module/ui/EmbeddedSettingsHost.kt | 4 +- ...honeLiquidGlassStructuralRegressionTest.kt | 19 +++- ...bletLiquidGlassStructuralRegressionTest.kt | 86 +++++++++++++++++++ 4 files changed, 117 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt diff --git a/README.md b/README.md index dca4911a..82f524fa 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

AM++

- Apple Music 的 Android 增强模块:平板双栏、歌词模糊与字体、自定义歌词、歌曲名修正、手机液态玻璃底栏。 + Apple Music 的 Android 增强模块:平板双栏、歌词模糊与字体、自定义歌词、歌曲名修正、液态玻璃底栏。

@@ -57,9 +57,9 @@ AM++ 通过 libxposed API 102 注入 Apple Music(`com.apple.android.music`) | 自定义歌词 | 关闭 | 按 Apple Music ID 注入 TTML,支持手动 TTML、AMLL、AM-Lyrics、Lunabeat 导入与 ZIP 备份恢复。 | | 自动实时补全 | 开启 | 自定义歌词开启后,为缺词、非逐字或缺翻译的歌曲自动查找歌词服务。 | | 歌词字体 | 关闭 | 导入 TTF/OTF 应用到播放器歌词,可一键恢复原字体。 | -| 手机液态玻璃底栏 | 关闭 | Android 13 及以上且 Apple Music 6.5.2/6.5.3 手机布局时,底栏与迷你播放器改用液态玻璃。 | -| 底栏高度 | `16dp` | 液态玻璃附加项:底栏距屏幕底部的距离 `0..48dp`。仅在“手机液态玻璃底栏”开启时显示和生效,需重开 Apple Music。 | -| 底栏背景模糊强度 | `4dp` | 液态玻璃附加项:底栏与迷你播放器的背景模糊半径 `0..24dp`。仅在“手机液态玻璃底栏”开启时显示和生效,需重开 Apple Music。 | +| 液态玻璃底栏 | 关闭 | Android 13 及以上且 Apple Music 6.5.2/6.5.3 时,把手机与“开启平板双栏播放器的平板横屏”的底栏与迷你播放器改用液态玻璃;平板竖屏或双栏关闭时保持原生界面。 | +| 底栏高度 | `16dp` | 液态玻璃附加项:底栏距屏幕底部的距离 `0..48dp`。仅在“液态玻璃底栏”开启时显示和生效,需重开 Apple Music。 | +| 底栏背景模糊强度 | `4dp` | 液态玻璃附加项:底栏与迷你播放器的背景模糊半径 `0..24dp`。仅在“液态玻璃底栏”开启时显示和生效,需重开 Apple Music。 | | 平板底栏补偿 | 关闭 | 平板底栏显示异常时使用的兼容选项。 | | Apple Music 内部 DPI | 跟随系统 | 只改 Apple Music 进程的资源密度,`160..640`,`0` 表示跟随系统。需完全重开 Apple Music。 | @@ -84,17 +84,17 @@ AM++ 通过 libxposed API 102 注入 Apple Music(`com.apple.android.music`) 平板横屏双栏播放器与歌词模糊

-### 手机液态玻璃底栏 +### 液态玻璃底栏

- 手机液态玻璃底栏与迷你播放器(主页与资料库) + 液态玻璃底栏与迷你播放器(主页与资料库)

## 兼容性与限制 | 项目 | 支持范围 | | --- | --- | -| Android | 8.0(API 26)及以上;双向歌词模糊需 12(API 31)及以上;手机液态玻璃需 13(API 33)及以上 | +| Android | 8.0(API 26)及以上;双向歌词模糊需 12(API 31)及以上;液态玻璃底栏需 13(API 33)及以上 | | Xposed 框架 | 实现 libxposed API 102、remote preferences 和 remote file 的框架 | | Apple Music | `6.5.1 (1583)`、`6.5.2 (1586)`、`6.5.3 (1599)` | @@ -103,7 +103,7 @@ AM++ 通过 libxposed API 102 注入 Apple Music(`com.apple.android.music`) - 6.5.3 (1599) 仍有两处降级,各自只影响一个子面:播放菜单/操作表的元数据改写、主页 Listen Now 封面连续性。 - 功能开关不会热卸载已安装的 Hook,改动后必须强制停止并重新打开 Apple Music。 - 自定义 TTML 上限 512 KiB。 -- 手机液态玻璃只作用于手机布局;平板和其他版本继续使用原生底栏。 +- 液态玻璃底栏作用于手机布局,以及开启“平板双栏播放器”的平板横屏;平板竖屏、关闭双栏的平板和其他版本继续使用原生底栏。平板形态的真机验收仍待进行。 ## 安装 @@ -149,9 +149,9 @@ Lunabeat 会缓存 manifest 和歌曲索引,只在远端 revision 变化时重 字体只覆盖播放器歌词,不修改系统字体或设置页字体。 -### 手机液态玻璃 +### 液态玻璃底栏 -打开“手机液态玻璃底栏”,强制停止并重新打开 Apple Music。底栏使用 AndroidLiquidGlass 的 LiquidBottomTabs,迷你播放器使用 LiquidButton 材质和按压形变,播放控件仍是原生实现;页面背景通过共享硬件 RenderNode 采样。逐项依赖与维护流程见 [液态玻璃新版本适配](docs/liquid-glass-adaptation.md)。 +打开“液态玻璃底栏”,强制停止并重新打开 Apple Music。生效范围是手机布局与开启“平板双栏播放器”的平板横屏(平板竖屏或双栏关闭时保持原生界面)。底栏使用 AndroidLiquidGlass 的 LiquidBottomTabs,迷你播放器使用 LiquidButton 材质和按压形变,播放控件仍是原生实现;页面背景通过共享硬件 RenderNode 采样。逐项依赖与维护流程见 [液态玻璃新版本适配](docs/liquid-glass-adaptation.md)。 开启后可微调两个附加项(关闭液态玻璃时不显示、也不生效):“底栏高度”(`0..48dp`,即底栏距屏幕底部的距离,同时调整内容底部留白与播放器 peek 高度)与“底栏背景模糊强度”(`0..24dp`,同时作用于底栏面板和迷你播放器);两者均为重开 Apple Music 后生效。每项右上角有小恢复按钮,可单独一键回到默认值(`16dp` / `4dp`)。 @@ -208,7 +208,7 @@ scripts/ 可选的真机回归、录屏分析与 host profile - [x] 双向歌词模糊 - [x] 自定义歌词注入与备份恢复 - [x] 歌词字体导入与恢复 -- [x] 手机液态玻璃底栏与迷你播放器 +- [x] 液态玻璃底栏与迷你播放器(手机;开启双栏的平板横屏生效范围待真机验收) - [x] 歌曲名显示修正 - [ ] 补齐 Apple Music 6.5.3 的两处降级 - [ ] 持续适配后续 Apple Music 版本 diff --git a/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt b/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt index e2bf9e04..5564c27e 100644 --- a/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt +++ b/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt @@ -2189,8 +2189,8 @@ internal class EmbeddedSettingsHost private constructor( addView(embeddedDivider(activity)) addView(embeddedSettingRow( activity, - "手机液态玻璃底栏", - "为手机底栏和迷你播放器启用液态玻璃效果,需重开应用", + "液态玻璃底栏", + "为手机与开启平板双栏播放器的平板横屏的底栏和迷你播放器启用液态玻璃效果,需重开应用", settings.phoneLiquidGlassEnabled, iconTint = EmbeddedSettingsPalette.accent, iconDrawable = EmbeddedGlyphDrawable( diff --git a/app/src/test/java/dev/amenhancer/module/hook/PhoneLiquidGlassStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/PhoneLiquidGlassStructuralRegressionTest.kt index 1ab42a1f..8f85f4fa 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/PhoneLiquidGlassStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/PhoneLiquidGlassStructuralRegressionTest.kt @@ -5,7 +5,7 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -/** Guards the phone-only liquid-glass resource and configuration contract. */ +/** Guards the liquid-glass resource and configuration contract across host forms. */ class PhoneLiquidGlassStructuralRegressionTest { private fun source(relativePath: String): String = sequenceOf( File("src/main/java/$relativePath"), @@ -60,6 +60,23 @@ class PhoneLiquidGlassStructuralRegressionTest { assertTrue(settings.contains("EmbeddedSvgIcon.RestoreDefault")) } + @Test + fun `keeps the phone form semantics and routes the tablet dual-pane form`() { + val policy = projectFile("glass/src/main/kotlin/dev/amenhancer/glass/GlassPolicy.kt") + val runtime = source("dev/amenhancer/module/hook/PhoneGlassRuntime.kt") + + // The pre-form supports() overload survives with its phone-only meaning. + assertTrue(policy.contains("fun supports(sdk: Int, versionCode: Long, versionName: String, tablet: Boolean)")) + // The phone path keeps excluding official tablets and keeps its own session. + assertTrue(runtime.contains("isOfficialTablet")) + assertTrue(runtime.contains("PhoneGlassSession(")) + // Eligible tablets are routed to the dedicated tablet session. + assertTrue(runtime.contains("TabletDualPaneGlassSession")) + // The form and geometry seams live in the Android-free policy. + assertTrue(policy.contains("GlassHostForm")) + assertTrue(policy.contains("GlassGeometry")) + } + @Test fun `uses api 102 remote preferences and keeps liquid glass fail closed`() { val manifest = projectFile("app/src/main/AndroidManifest.xml") diff --git a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt new file mode 100644 index 00000000..ea2bc098 --- /dev/null +++ b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt @@ -0,0 +1,86 @@ +package dev.amenhancer.module.hook + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Guards the tablet dual-pane + liquid-glass combination contract: the tablet session is + * gated behind the dual-pane form and the shared toggle, the dual-pane boundary sync stays + * muted while glass owns the collapsed geometry, and the configuration schema is untouched + * (no new keys, no migration). + */ +class TabletLiquidGlassStructuralRegressionTest { + private fun source(relativePath: String): String = sequenceOf( + File("src/main/java/$relativePath"), + File("app/src/main/java/$relativePath"), + ).firstOrNull(File::isFile)?.readText() + ?: error("$relativePath was not found from the unit-test working directory") + + /** Collapses line wraps so multi-line expressions can be matched as written prose. */ + private fun normalized(text: String): String = text.replace(Regex("\\s+"), " ") + + @Test + fun `builds the tablet glass session behind the eligibility and toggle gates`() { + val runtime = source("dev/amenhancer/module/hook/PhoneGlassRuntime.kt") + val phoneSession = source("dev/amenhancer/module/hook/PhoneGlassSession.kt") + val tabletSession = source("dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt") + + // The tablet session may be constructed from the runtime dispatch or the session + // factory; wherever it is built, the form eligibility and the user toggle must + // already have been decided. + val constructionSites = listOf(runtime, phoneSession).map { normalized(it) } + .map { text -> text.indexOf("TabletDualPaneGlassSession(") to text } + .filter { (index, _) -> index >= 0 } + assertTrue( + "TabletDualPaneGlassSession must be constructed from PhoneGlassRuntime/PhoneGlassSession", + constructionSites.isNotEmpty(), + ) + constructionSites.forEach { (build, text) -> + val gates = text.substring(maxOf(0, build - 600), build) + assertTrue(gates.contains("TabletModeQualifier.isEligible")) + assertTrue(gates.contains("phoneLiquidGlassEnabled")) + } + + // The session itself re-checks the same predicate for activation/liveness. + val tablet = normalized(tabletSession) + assertTrue(tablet.contains("phoneLiquidGlassEnabled")) + assertTrue(tablet.contains("TabletModeQualifier.isEligible")) + } + + @Test + fun `mutes the dual-pane boundary sync while glass owns the collapsed geometry`() { + val dualPane = normalized( + source("dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt"), + ) + val guard = dualPane.indexOf("TabletGlassChrome.isGlassActive(root)") + // Only the settled writes match these strings; the comparison reads use `!=`. + val translationWrite = dualPane.indexOf("playerContainer.translationY = desiredTranslation") + val visibilityWrite = dualPane.indexOf("tabsFrame.visibility = desiredTabsVisibility") + assertTrue(guard >= 0) + // Both sync() writes must sit behind the arbitration guard. + assertTrue(translationWrite > guard) + assertTrue(visibilityWrite > guard) + } + + @Test + fun `keeps the glass configuration keys and schema version unchanged`() { + val schema = source("dev/amenhancer/module/config/ModuleSettingsSchema.kt") + val constants = source("dev/amenhancer/module/ModuleConstants.kt") + + // Exactly the three existing glass keys: the tablet form reuses the phone toggle, + // gap and blur, so no new key exists and no migration was added. + val glassKeys = Regex("\"(phone_liquid_glass[a-z_]*)\"").findAll(schema) + .map { it.groupValues[1] }.toSet() + assertEquals( + setOf( + "phone_liquid_glass_enabled", + "phone_liquid_glass_bottom_gap_dp", + "phone_liquid_glass_panel_blur_dp", + ), + glassKeys, + ) + assertTrue(constants.contains("const val CONFIG_SCHEMA_VERSION = 14")) + } +} From b2fc49e359f97e3d9dcccb02c55f9c0ed1e2cbd8 Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 23 Sep 2026 12:17:59 +0800 Subject: [PATCH 04/19] fix(glass): suppress flat chrome seams under the tablet capsule --- .../module/hook/PhoneGlassSession.kt | 24 ++++++++++++++++--- .../module/hook/TabletDualPaneGlassSession.kt | 15 ++++++++++++ ...bletLiquidGlassStructuralRegressionTest.kt | 12 ++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt index 194e5920..fb9e82ff 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt @@ -71,7 +71,7 @@ internal open class PhoneGlassSession( private var observer: ViewTreeObserver? = null private var closed = false private var failureScheduled = false - private var hostRoot: View? = null + protected var hostRoot: View? = null final override var activated = false private set private var tabs by mutableStateOf(emptyList()) @@ -119,7 +119,7 @@ internal open class PhoneGlassSession( // configuration changes and replaced page/player hierarchies still take effect. private val resourceIds = HashMap() - private fun resourceId(name: String, type: String): Int { + protected fun resourceId(name: String, type: String): Int { val key = "$type/$name" resourceIds[key]?.let { return it } val id = activity.resources.getIdentifier(name, type, ModuleConstants.TARGET_PACKAGE) @@ -135,6 +135,23 @@ internal open class PhoneGlassSession( private fun save(view: View): NativeViewState = states.getOrPut(view) { NativeViewState(view) } + /** + * Native chrome seams must stay gone under the floating capsule. The phone + * host carries only the tabs divider; the flat host adds more (see the + * tablet session). Idempotent compare-then-write, run at activation and on + * every transition frame, so a late (re)creation by host or installer code + * cannot resurrect a seam; close() restores the saved states. + */ + protected open fun suppressNativeChromeSeams() { + hideSeam(find("navigation_tabs_divider")) + } + + protected fun hideSeam(view: View?) { + view ?: return + save(view) + if (view.visibility != View.GONE) view.visibility = View.GONE + } + private fun allowGlassOverflow(view: View) { generateSequence(view as View?) { it.parent as? View }.takeWhile { it.layoutParams != null }.forEach { if (it is ViewGroup) { @@ -358,7 +375,7 @@ internal open class PhoneGlassSession( frame.clipChildren = false frame.clipToPadding = false allowGlassOverflow(frame) - find("navigation_tabs_divider")?.let { save(it); it.visibility = View.GONE } + suppressNativeChromeSeams() source?.let { save(it) } nativePeek.initialize(nativePeekBaseline()) activated = true @@ -488,6 +505,7 @@ internal open class PhoneGlassSession( } private fun updateTransition() { + suppressNativeChromeSeams() navFrame?.background = null // Keep Z ordering (also used for touch dispatch); remove only the old // rectangular shadow outline, not the navigation view's elevation. diff --git a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt index ab15c2bf..1b5983ca 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt @@ -53,6 +53,21 @@ internal class TabletDualPaneGlassSession( root?.let(TabletGlassChrome::clearGlassActive) } + // Flat-only chrome survives the dual-pane full-width transform as hairlines + // across/over the floating capsule: nav_tabs_top_shadow is a dp gradient + // strip riding the tabs frame top edge, and the stock column divider (1dp + // separator_color, drawn above the tabs frame in z) keeps anchors to both + // pre-transform columns and resolves to a stray vertical line mid-screen. + // The glass capsule replaces both; the shared seam hook keeps them gone. + override fun suppressNativeChromeSeams() { + super.suppressNativeChromeSeams() + val root = hostRoot ?: return + for (name in listOf("nav_tabs_top_shadow", "divider")) { + val id = resourceId(name, "id").takeIf { it != 0 } ?: continue + hideSeam(root.findViewById(id)) + } + } + // The host field declares BottomSheetBehavior but runs // PlayerBottomSheetBehavior (and is the activity's only Behavior field). Prefer a // value whose runtime class names it; fall back to the phone declared-type scan. diff --git a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt index ea2bc098..8d3ef795 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt @@ -64,6 +64,18 @@ class TabletLiquidGlassStructuralRegressionTest { assertTrue(visibilityWrite > guard) } + @Test + fun `suppresses the flat chrome seams under the tablet capsule`() { + val session = source("dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt") + val base = source("dev/amenhancer/module/hook/PhoneGlassSession.kt") + // The flat top-shadow strip and the stock column divider must be held + // gone for the whole session, not only at activation: the host may + // recolor/recreate them behind the floating capsule. + assertTrue(session.contains("nav_tabs_top_shadow")) + assertTrue(session.contains("suppressNativeChromeSeams")) + assertTrue(base.contains("suppressNativeChromeSeams")) + } + @Test fun `keeps the glass configuration keys and schema version unchanged`() { val schema = source("dev/amenhancer/module/config/ModuleSettingsSchema.kt") From 9aaa871c7fd27f83ee999151e3834556e5f7da59 Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 23 Sep 2026 12:32:10 +0800 Subject: [PATCH 05/19] feat(glass): shorten the tablet capsules to two thirds of the host width --- README.md | 2 +- .../module/hook/PhoneGlassSession.kt | 24 ++++++++++++++++--- .../module/hook/TabletDualPaneGlassSession.kt | 5 ++++ ...bletLiquidGlassStructuralRegressionTest.kt | 11 +++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 82f524fa..0c8f65f1 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Lunabeat 会缓存 manifest 和歌曲索引,只在远端 revision 变化时重 ### 液态玻璃底栏 -打开“液态玻璃底栏”,强制停止并重新打开 Apple Music。生效范围是手机布局与开启“平板双栏播放器”的平板横屏(平板竖屏或双栏关闭时保持原生界面)。底栏使用 AndroidLiquidGlass 的 LiquidBottomTabs,迷你播放器使用 LiquidButton 材质和按压形变,播放控件仍是原生实现;页面背景通过共享硬件 RenderNode 采样。逐项依赖与维护流程见 [液态玻璃新版本适配](docs/liquid-glass-adaptation.md)。 +打开“液态玻璃底栏”,强制停止并重新打开 Apple Music。生效范围是手机布局与开启“平板双栏播放器”的平板横屏(平板竖屏或双栏关闭时保持原生界面);平板形态下底栏与迷你播放器两个胶囊居中悬浮、宽约屏幕 2/3。底栏使用 AndroidLiquidGlass 的 LiquidBottomTabs,迷你播放器使用 LiquidButton 材质和按压形变,播放控件仍是原生实现;页面背景通过共享硬件 RenderNode 采样。逐项依赖与维护流程见 [液态玻璃新版本适配](docs/liquid-glass-adaptation.md)。 开启后可微调两个附加项(关闭液态玻璃时不显示、也不生效):“底栏高度”(`0..48dp`,即底栏距屏幕底部的距离,同时调整内容底部留白与播放器 peek 高度)与“底栏背景模糊强度”(`0..24dp`,同时作用于底栏面板和迷你播放器);两者均为重开 Apple Music 后生效。每项右上角有小恢复按钮,可单独一键回到默认值(`16dp` / `4dp`)。 diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt index fb9e82ff..e7cb4016 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt @@ -84,6 +84,7 @@ internal open class PhoneGlassSession( private var slide = 0f private var glassExpansion by androidx.compose.runtime.mutableFloatStateOf(0f) private var miniOffsetInSheet = 0 + private var capsuleMarginPx = 0 private var lastPeek = -1 private val nativePeek = NativePeekHeight() private val attachHandler = android.os.Handler(android.os.Looper.getMainLooper()) @@ -115,6 +116,9 @@ internal open class PhoneGlassSession( /** Capsule geometry shared by every occupied-height consumer; a diverging form overrides this. */ protected open val geometry: GlassGeometry get() = GlassGeometry.Phone + /** Side margin of both floating capsules; wide hosts may shorten the pills. */ + protected open fun capsuleSideMarginPx(frameWidth: Int): Int = dp(geometry.horizontalDp) + // Resource IDs are stable for this Activity's host APK. Keep values and Views live so // configuration changes and replaced page/player hierarchies still take effect. private val resourceIds = HashMap() @@ -224,8 +228,9 @@ internal open class PhoneGlassSession( val glass = GlassHostView(moduleContext()).also { navGlass = it } glass.alpha = 0f glass.content { HostConfiguration { GlassNavigation(tabs, selectedId, accent, foreground, bg, ::selectTab, panelBlur = navBlurDp.dp) } } + capsuleMarginPx = capsuleSideMarginPx(frame.width) frame.addView(glass, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(GlassPolicy.NAV_HEIGHT_DP), Gravity.TOP).apply { - leftMargin = dp(16); rightMargin = dp(16) + leftMargin = capsuleMarginPx; rightMargin = capsuleMarginPx }) observer = activity.window.decorView.viewTreeObserver.also { it.addOnPreDrawListener(this); it.addOnGlobalLayoutListener(layoutListener) } } @@ -251,7 +256,7 @@ internal open class PhoneGlassSession( // Keep the material behind the whole sheet, independent of that container. val surfaceParent = find("player_sheet_container") as? FrameLayout ?: root surfaceParent.addView(glass, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(GlassPolicy.MINI_HEIGHT_DP), Gravity.TOP).apply { - leftMargin = dp(16); rightMargin = dp(16) + leftMargin = capsuleMarginPx; rightMargin = capsuleMarginPx }) if (activated) prepareMini() } @@ -422,6 +427,19 @@ internal open class PhoneGlassSession( navFrame?.let { frame -> // A generic copy constructor drops the host ConstraintLayout's bottom anchor. if (frame.layoutParams.height != height) frame.layoutParams = frame.layoutParams.apply { this.height = height } + // The capsule width may follow the host width (see the tablet form); + // resync both surfaces whenever the resolved side margin changes. + val margin = capsuleSideMarginPx(frame.width) + if (margin != capsuleMarginPx) { + capsuleMarginPx = margin + listOfNotNull(navGlass, miniGlass).forEach { glass -> + val params = glass.layoutParams as? FrameLayout.LayoutParams ?: return@forEach + if (params.leftMargin != margin || params.rightMargin != margin) { + params.leftMargin = margin; params.rightMargin = margin + glass.layoutParams = params + } + } + } } val peek = peekHeight() if (lastPeek != peek) { @@ -527,7 +545,7 @@ internal open class PhoneGlassSession( val sheetPosition = IntArray(2).also(sheet::getLocationInWindow) miniOffsetInSheet = miniPosition[1] - sheetPosition[1] } - val margin = (dp(16) * (1f - materialProgress)).roundToInt() + val margin = (capsuleMarginPx * (1f - materialProgress)).roundToInt() val top = (miniOffsetInSheet * (1f - materialProgress)).roundToInt() val collapsedHeight = dp(GlassPolicy.MINI_HEIGHT_DP) val height = (collapsedHeight + (sheet.height - collapsedHeight) * progress).roundToInt().coerceAtLeast(collapsedHeight) diff --git a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt index 1b5983ca..a8c6aa4b 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt @@ -23,6 +23,11 @@ internal class TabletDualPaneGlassSession( override val geometry: GlassGeometry get() = GlassGeometry.Tablet + // User-tuned tablet proportion (2026-09-22): full-width capsules looked + // stretched on a tablet, so both floating pills take two thirds of the host + // width and center — one sixth of the width as side margin. + override fun capsuleSideMarginPx(frameWidth: Int): Int = frameWidth / 6 + // The session lives only while the official tablet runs the dual-pane player; // portrait or dual-pane-off restores the native chrome through close(). override fun sessionEligible(): Boolean = diff --git a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt index 8d3ef795..e9456883 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt @@ -64,6 +64,17 @@ class TabletLiquidGlassStructuralRegressionTest { assertTrue(visibilityWrite > guard) } + @Test + fun `shortens the tablet capsules to two thirds of the host width`() { + val session = source("dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt") + val base = source("dev/amenhancer/module/hook/PhoneGlassSession.kt") + // The phone keeps its tuned 16dp margins (capsuleSideMarginPx default); + // only the tablet form shortens both floating capsules by taking + // frameWidth/6 side margins, resynced whenever the frame width settles. + assertTrue(session.contains("frameWidth / 6")) + assertTrue(base.contains("capsuleSideMarginPx")) + } + @Test fun `suppresses the flat chrome seams under the tablet capsule`() { val session = source("dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt") From 5fcbf60ad5b6f4cc535546d16044d00e1916f817 Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 23 Sep 2026 12:37:44 +0800 Subject: [PATCH 06/19] fix(glass): keep the mini content aligned with the shortened capsule --- .../java/dev/amenhancer/module/hook/PhoneGlassSession.kt | 8 ++++---- .../hook/TabletLiquidGlassStructuralRegressionTest.kt | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt index e7cb4016..5f0954ad 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt @@ -406,7 +406,7 @@ internal open class PhoneGlassSession( save(content) val params = content.layoutParams params.height = dp(GlassPolicy.MINI_HEIGHT_DP) - if (params is ViewGroup.MarginLayoutParams) { params.leftMargin = dp(16); params.rightMargin = dp(16) } + if (params is ViewGroup.MarginLayoutParams) { params.leftMargin = capsuleMarginPx; params.rightMargin = capsuleMarginPx } content.layoutParams = params listOf("video_surface_container", "mini_player_play_btn", "mini_player_next_btn").forEach { name -> val id = activity.resources.getIdentifier(name, "id", ModuleConstants.TARGET_PACKAGE) @@ -432,11 +432,11 @@ internal open class PhoneGlassSession( val margin = capsuleSideMarginPx(frame.width) if (margin != capsuleMarginPx) { capsuleMarginPx = margin - listOfNotNull(navGlass, miniGlass).forEach { glass -> - val params = glass.layoutParams as? FrameLayout.LayoutParams ?: return@forEach + listOfNotNull(navGlass, miniGlass, miniContent).forEach { surface -> + val params = surface.layoutParams as? ViewGroup.MarginLayoutParams ?: return@forEach if (params.leftMargin != margin || params.rightMargin != margin) { params.leftMargin = margin; params.rightMargin = margin - glass.layoutParams = params + surface.layoutParams = params } } } diff --git a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt index e9456883..b92b497b 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt @@ -73,6 +73,9 @@ class TabletLiquidGlassStructuralRegressionTest { // frameWidth/6 side margins, resynced whenever the frame width settles. assertTrue(session.contains("frameWidth / 6")) assertTrue(base.contains("capsuleSideMarginPx")) + // The native mini content must follow the same margins, or its artwork, + // title and playback buttons drift outside the shortened capsule. + assertTrue(base.contains("navGlass, miniGlass, miniContent")) } @Test From 1a55c94f76222e1d7eb431eeed9c1775e91c1eb4 Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 23 Sep 2026 12:57:14 +0800 Subject: [PATCH 07/19] fix(dual-pane): release the compensation writes when glass takes over --- README.md | 2 +- .../amenhancer/module/hook/AppleMusicDualPaneTarget.kt | 10 +++++++++- .../hook/TabletLiquidGlassStructuralRegressionTest.kt | 3 +++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0c8f65f1..8f7b5da8 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ AM++ 通过 libxposed API 102 注入 Apple Music(`com.apple.android.music`) | 液态玻璃底栏 | 关闭 | Android 13 及以上且 Apple Music 6.5.2/6.5.3 时,把手机与“开启平板双栏播放器的平板横屏”的底栏与迷你播放器改用液态玻璃;平板竖屏或双栏关闭时保持原生界面。 | | 底栏高度 | `16dp` | 液态玻璃附加项:底栏距屏幕底部的距离 `0..48dp`。仅在“液态玻璃底栏”开启时显示和生效,需重开 Apple Music。 | | 底栏背景模糊强度 | `4dp` | 液态玻璃附加项:底栏与迷你播放器的背景模糊半径 `0..24dp`。仅在“液态玻璃底栏”开启时显示和生效,需重开 Apple Music。 | -| 平板底栏补偿 | 关闭 | 平板底栏显示异常时使用的兼容选项。 | +| 平板底栏补偿 | 关闭 | 平板底栏显示异常时使用的兼容选项;液态玻璃底栏开启期间由玻璃接管底栏几何,此开关不生效。 | | Apple Music 内部 DPI | 跟随系统 | 只改 Apple Music 进程的资源密度,`160..640`,`0` 表示跟随系统。需完全重开 Apple Music。 | ## 效果展示 diff --git a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt index 059641ec..9c8c7ad4 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt @@ -1640,7 +1640,15 @@ private object ConstraintLayoutPane { // latch semantics are preserved; once glass clears (switch off, // predicate false, fail-closed recovery) the compare-then-write // mechanism below re-asserts the settled values byte-identically. - if (TabletGlassChrome.isGlassActive(root)) return + if (TabletGlassChrome.isGlassActive(root)) { + // Hand-over release: settle the compensation's own writes once + // so glass starts from clean geometry. A stuck lift would keep + // the mini capsule too high; a stuck INVISIBLE tabs frame would + // hide the glass surfaces entirely. + if (playerContainer.translationY != 0f) playerContainer.translationY = 0f + if (tabsFrame.visibility != View.VISIBLE) tabsFrame.visibility = View.VISIBLE + return + } val rootHeight = root.height if (rootHeight <= 0) return root.getLocationInWindow(rootLocation) diff --git a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt index b92b497b..5c22f6ee 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt @@ -62,6 +62,9 @@ class TabletLiquidGlassStructuralRegressionTest { // Both sync() writes must sit behind the arbitration guard. assertTrue(translationWrite > guard) assertTrue(visibilityWrite > guard) + // The hand-over releases a settled compensation lift/visibility so the + // glass geometry starts from a clean state instead of inheriting it. + assertTrue(dualPane.contains("playerContainer.translationY = 0f")) } @Test From f60f3ef6083cc9f115566d8ee783c0c84d3c7c27 Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 23 Sep 2026 13:12:07 +0800 Subject: [PATCH 08/19] feat(glass): halve the tablet capsule width and hide the inert compensation row --- README.md | 4 +-- .../module/hook/TabletDualPaneGlassSession.kt | 8 ++--- .../module/ui/EmbeddedSettingsHost.kt | 29 +++++++++++-------- ...bletLiquidGlassStructuralRegressionTest.kt | 15 ++++++++-- 4 files changed, 35 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 8f7b5da8..d735ceb4 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ AM++ 通过 libxposed API 102 注入 Apple Music(`com.apple.android.music`) | 液态玻璃底栏 | 关闭 | Android 13 及以上且 Apple Music 6.5.2/6.5.3 时,把手机与“开启平板双栏播放器的平板横屏”的底栏与迷你播放器改用液态玻璃;平板竖屏或双栏关闭时保持原生界面。 | | 底栏高度 | `16dp` | 液态玻璃附加项:底栏距屏幕底部的距离 `0..48dp`。仅在“液态玻璃底栏”开启时显示和生效,需重开 Apple Music。 | | 底栏背景模糊强度 | `4dp` | 液态玻璃附加项:底栏与迷你播放器的背景模糊半径 `0..24dp`。仅在“液态玻璃底栏”开启时显示和生效,需重开 Apple Music。 | -| 平板底栏补偿 | 关闭 | 平板底栏显示异常时使用的兼容选项;液态玻璃底栏开启期间由玻璃接管底栏几何,此开关不生效。 | +| 平板底栏补偿 | 关闭 | 平板底栏显示异常时使用的兼容选项;液态玻璃底栏开启期间由玻璃接管底栏几何,此开关不生效,且设置行在「液态玻璃底栏」开启时隐藏。 | | Apple Music 内部 DPI | 跟随系统 | 只改 Apple Music 进程的资源密度,`160..640`,`0` 表示跟随系统。需完全重开 Apple Music。 | ## 效果展示 @@ -151,7 +151,7 @@ Lunabeat 会缓存 manifest 和歌曲索引,只在远端 revision 变化时重 ### 液态玻璃底栏 -打开“液态玻璃底栏”,强制停止并重新打开 Apple Music。生效范围是手机布局与开启“平板双栏播放器”的平板横屏(平板竖屏或双栏关闭时保持原生界面);平板形态下底栏与迷你播放器两个胶囊居中悬浮、宽约屏幕 2/3。底栏使用 AndroidLiquidGlass 的 LiquidBottomTabs,迷你播放器使用 LiquidButton 材质和按压形变,播放控件仍是原生实现;页面背景通过共享硬件 RenderNode 采样。逐项依赖与维护流程见 [液态玻璃新版本适配](docs/liquid-glass-adaptation.md)。 +打开“液态玻璃底栏”,强制停止并重新打开 Apple Music。生效范围是手机布局与开启“平板双栏播放器”的平板横屏(平板竖屏或双栏关闭时保持原生界面);平板形态下底栏与迷你播放器两个胶囊居中悬浮、宽约屏幕 1/2。底栏使用 AndroidLiquidGlass 的 LiquidBottomTabs,迷你播放器使用 LiquidButton 材质和按压形变,播放控件仍是原生实现;页面背景通过共享硬件 RenderNode 采样。逐项依赖与维护流程见 [液态玻璃新版本适配](docs/liquid-glass-adaptation.md)。 开启后可微调两个附加项(关闭液态玻璃时不显示、也不生效):“底栏高度”(`0..48dp`,即底栏距屏幕底部的距离,同时调整内容底部留白与播放器 peek 高度)与“底栏背景模糊强度”(`0..24dp`,同时作用于底栏面板和迷你播放器);两者均为重开 Apple Music 后生效。每项右上角有小恢复按钮,可单独一键回到默认值(`16dp` / `4dp`)。 diff --git a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt index a8c6aa4b..e5d0f1df 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt @@ -23,10 +23,10 @@ internal class TabletDualPaneGlassSession( override val geometry: GlassGeometry get() = GlassGeometry.Tablet - // User-tuned tablet proportion (2026-09-22): full-width capsules looked - // stretched on a tablet, so both floating pills take two thirds of the host - // width and center — one sixth of the width as side margin. - override fun capsuleSideMarginPx(frameWidth: Int): Int = frameWidth / 6 + // User-tuned tablet proportion (2026-09-22, second pass): full-width + // capsules looked stretched on a tablet; both floating pills now take half + // of the host width and center — one quarter of the width as side margin. + override fun capsuleSideMarginPx(frameWidth: Int): Int = frameWidth / 4 // The session lives only while the official tablet runs the dual-pane player; // portrait or dual-pane-off restores the native chrome through close(). diff --git a/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt b/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt index 5564c27e..48b71ee3 100644 --- a/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt +++ b/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt @@ -2174,18 +2174,23 @@ internal class EmbeddedSettingsHost private constructor( EmbeddedSettingsPalette.primary, ), ) { onSettingsChanged(settings.copy(dualPaneEnabled = it)) }) - addView(embeddedDivider(activity)) - addView(embeddedSettingRow( - activity, - "平板底栏补偿", - "如果底栏显示异常开启该选项", - settings.navigationCompensationEnabled, - iconTint = EmbeddedSettingsPalette.primary, - iconDrawable = EmbeddedGlyphDrawable( - EmbeddedGlyphKind.BottomBar, - EmbeddedSettingsPalette.primary, - ), - ) { onSettingsChanged(settings.copy(navigationCompensationEnabled = it)) }) + // The compensation toggle only matters for the native tablet bar: + // liquid glass owns the bottom geometry while it is on, so hide the + // row instead of showing a switch that silently does nothing. + if (!settings.phoneLiquidGlassEnabled) { + addView(embeddedDivider(activity)) + addView(embeddedSettingRow( + activity, + "平板底栏补偿", + "如果底栏显示异常开启该选项", + settings.navigationCompensationEnabled, + iconTint = EmbeddedSettingsPalette.primary, + iconDrawable = EmbeddedGlyphDrawable( + EmbeddedGlyphKind.BottomBar, + EmbeddedSettingsPalette.primary, + ), + ) { onSettingsChanged(settings.copy(navigationCompensationEnabled = it)) }) + } addView(embeddedDivider(activity)) addView(embeddedSettingRow( activity, diff --git a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt index 5c22f6ee..720c64dc 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt @@ -68,19 +68,28 @@ class TabletLiquidGlassStructuralRegressionTest { } @Test - fun `shortens the tablet capsules to two thirds of the host width`() { + fun `shortens the tablet capsules to half of the host width`() { val session = source("dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt") val base = source("dev/amenhancer/module/hook/PhoneGlassSession.kt") // The phone keeps its tuned 16dp margins (capsuleSideMarginPx default); // only the tablet form shortens both floating capsules by taking - // frameWidth/6 side margins, resynced whenever the frame width settles. - assertTrue(session.contains("frameWidth / 6")) + // frameWidth/4 side margins, resynced whenever the frame width settles. + assertTrue(session.contains("frameWidth / 4")) assertTrue(base.contains("capsuleSideMarginPx")) // The native mini content must follow the same margins, or its artwork, // title and playback buttons drift outside the shortened capsule. assertTrue(base.contains("navGlass, miniGlass, miniContent")) } + @Test + fun `hides the compensation row while the glass toggle is on`() { + val settings = source("dev/amenhancer/module/ui/EmbeddedSettingsHost.kt") + // The compensation toggle is native-bar-only; showing it while glass + // owns the geometry would read as a live switch that does nothing. + assertTrue(settings.contains("if (!settings.phoneLiquidGlassEnabled)")) + assertTrue(settings.contains("平板底栏补偿")) + } + @Test fun `suppresses the flat chrome seams under the tablet capsule`() { val session = source("dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt") From d8b0ac06f0f9b75bf893a055206871a38f4a27f5 Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 23 Sep 2026 13:30:58 +0800 Subject: [PATCH 09/19] feat(glass): lay the tablet capsules side by side in one centered row --- README.md | 2 +- .../module/hook/PhoneGlassSession.kt | 71 +++++++++++++------ .../module/hook/TabletDualPaneGlassSession.kt | 12 ++-- ...bletLiquidGlassStructuralRegressionTest.kt | 19 ++--- .../dev/amenhancer/glass/GlassPolicy.kt | 8 ++- .../dev/amenhancer/glass/GlassGeometryTest.kt | 19 +++-- 6 files changed, 87 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index d735ceb4..a2ab35f5 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Lunabeat 会缓存 manifest 和歌曲索引,只在远端 revision 变化时重 ### 液态玻璃底栏 -打开“液态玻璃底栏”,强制停止并重新打开 Apple Music。生效范围是手机布局与开启“平板双栏播放器”的平板横屏(平板竖屏或双栏关闭时保持原生界面);平板形态下底栏与迷你播放器两个胶囊居中悬浮、宽约屏幕 1/2。底栏使用 AndroidLiquidGlass 的 LiquidBottomTabs,迷你播放器使用 LiquidButton 材质和按压形变,播放控件仍是原生实现;页面背景通过共享硬件 RenderNode 采样。逐项依赖与维护流程见 [液态玻璃新版本适配](docs/liquid-glass-adaptation.md)。 +打开“液态玻璃底栏”,强制停止并重新打开 Apple Music。生效范围是手机布局与开启“平板双栏播放器”的平板横屏(平板竖屏或双栏关闭时保持原生界面);平板形态下底栏与迷你播放器并排在同一条底部胶囊行内(底栏在左、迷你在右、同高居中),整行约 2/3 屏宽居中、左右留白等长,展开播放器时右下小胶囊四边插值摊开为全屏。底栏使用 AndroidLiquidGlass 的 LiquidBottomTabs,迷你播放器使用 LiquidButton 材质和按压形变,播放控件仍是原生实现;页面背景通过共享硬件 RenderNode 采样。逐项依赖与维护流程见 [液态玻璃新版本适配](docs/liquid-glass-adaptation.md)。 开启后可微调两个附加项(关闭液态玻璃时不显示、也不生效):“底栏高度”(`0..48dp`,即底栏距屏幕底部的距离,同时调整内容底部留白与播放器 peek 高度)与“底栏背景模糊强度”(`0..24dp`,同时作用于底栏面板和迷你播放器);两者均为重开 Apple Music 后生效。每项右上角有小恢复按钮,可单独一键回到默认值(`16dp` / `4dp`)。 diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt index 5f0954ad..d862b29d 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt @@ -84,7 +84,8 @@ internal open class PhoneGlassSession( private var slide = 0f private var glassExpansion by androidx.compose.runtime.mutableFloatStateOf(0f) private var miniOffsetInSheet = 0 - private var capsuleMarginPx = 0 + private var navMarginPx = intArrayOf(0, 0) + private var miniMarginPx = intArrayOf(0, 0) private var lastPeek = -1 private val nativePeek = NativePeekHeight() private val attachHandler = android.os.Handler(android.os.Looper.getMainLooper()) @@ -116,8 +117,15 @@ internal open class PhoneGlassSession( /** Capsule geometry shared by every occupied-height consumer; a diverging form overrides this. */ protected open val geometry: GlassGeometry get() = GlassGeometry.Phone - /** Side margin of both floating capsules; wide hosts may shorten the pills. */ - protected open fun capsuleSideMarginPx(frameWidth: Int): Int = dp(geometry.horizontalDp) + /** + * Horizontal slot of a floating capsule as [left, right] margins. The phone + * keeps the tuned symmetric margins; the tablet row carves asymmetric slots + * (nav pill left, mini pill right) inside one centered row. + */ + protected open fun capsuleMarginsPx(frameWidth: Int, mini: Boolean): IntArray { + val side = dp(geometry.horizontalDp) + return intArrayOf(side, side) + } // Resource IDs are stable for this Activity's host APK. Keep values and Views live so // configuration changes and replaced page/player hierarchies still take effect. @@ -228,9 +236,9 @@ internal open class PhoneGlassSession( val glass = GlassHostView(moduleContext()).also { navGlass = it } glass.alpha = 0f glass.content { HostConfiguration { GlassNavigation(tabs, selectedId, accent, foreground, bg, ::selectTab, panelBlur = navBlurDp.dp) } } - capsuleMarginPx = capsuleSideMarginPx(frame.width) + val navSlot = capsuleMarginsPx(frame.width, mini = false) frame.addView(glass, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(GlassPolicy.NAV_HEIGHT_DP), Gravity.TOP).apply { - leftMargin = capsuleMarginPx; rightMargin = capsuleMarginPx + leftMargin = navSlot[0]; rightMargin = navSlot[1] }) observer = activity.window.decorView.viewTreeObserver.also { it.addOnPreDrawListener(this); it.addOnGlobalLayoutListener(layoutListener) } } @@ -255,8 +263,9 @@ internal open class PhoneGlassSession( // The native mini container disappears early in the opening animation. // Keep the material behind the whole sheet, independent of that container. val surfaceParent = find("player_sheet_container") as? FrameLayout ?: root - surfaceParent.addView(glass, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(GlassPolicy.MINI_HEIGHT_DP), Gravity.TOP).apply { - leftMargin = capsuleMarginPx; rightMargin = capsuleMarginPx + surfaceParent.addView(glass, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(geometry.miniHeightDp), Gravity.TOP).apply { + val slot = capsuleMarginsPx(0, mini = true) + leftMargin = slot[0]; rightMargin = slot[1] }) if (activated) prepareMini() } @@ -400,13 +409,19 @@ internal open class PhoneGlassSession( miniRoot?.let(::allowGlassOverflow) miniRoot?.let { save(it); it.background = null; it.clipChildren = false; it.clipToPadding = false } miniRoot?.let { root -> - root.layoutParams = root.layoutParams.apply { height = dp(GlassPolicy.MINI_HEIGHT_DP) } + root.layoutParams = root.layoutParams.apply { height = dp(geometry.miniHeightDp) } } miniContent?.let { content -> save(content) val params = content.layoutParams - params.height = dp(GlassPolicy.MINI_HEIGHT_DP) - if (params is ViewGroup.MarginLayoutParams) { params.leftMargin = capsuleMarginPx; params.rightMargin = capsuleMarginPx } + val contentHeight = dp(minOf(GlassPolicy.MINI_HEIGHT_DP, geometry.miniHeightDp)) + params.height = contentHeight + if (params is ViewGroup.MarginLayoutParams) { + val slot = capsuleMarginsPx(0, mini = true) + params.leftMargin = slot[0]; params.rightMargin = slot[1] + val topOffset = (dp(geometry.miniHeightDp) - contentHeight) / 2 + if (topOffset != 0) params.topMargin = topOffset + } content.layoutParams = params listOf("video_surface_container", "mini_player_play_btn", "mini_player_next_btn").forEach { name -> val id = activity.resources.getIdentifier(name, "id", ModuleConstants.TARGET_PACKAGE) @@ -427,18 +442,24 @@ internal open class PhoneGlassSession( navFrame?.let { frame -> // A generic copy constructor drops the host ConstraintLayout's bottom anchor. if (frame.layoutParams.height != height) frame.layoutParams = frame.layoutParams.apply { this.height = height } - // The capsule width may follow the host width (see the tablet form); - // resync both surfaces whenever the resolved side margin changes. - val margin = capsuleSideMarginPx(frame.width) - if (margin != capsuleMarginPx) { - capsuleMarginPx = margin - listOfNotNull(navGlass, miniGlass, miniContent).forEach { surface -> - val params = surface.layoutParams as? ViewGroup.MarginLayoutParams ?: return@forEach - if (params.leftMargin != margin || params.rightMargin != margin) { - params.leftMargin = margin; params.rightMargin = margin + // The capsule slots may follow the host width (see the tablet row); + // resync every surface whenever a resolved slot edge changes. + val navSlot = capsuleMarginsPx(frame.width, mini = false) + val miniSlot = capsuleMarginsPx(frame.width, mini = true) + if (!navSlot.contentEquals(navMarginPx) || !miniSlot.contentEquals(miniMarginPx)) { + navMarginPx = navSlot + miniMarginPx = miniSlot + fun applySlot(view: View?, slot: IntArray) { + val surface = view ?: return + val params = surface.layoutParams as? ViewGroup.MarginLayoutParams ?: return + if (params.leftMargin != slot[0] || params.rightMargin != slot[1]) { + params.leftMargin = slot[0]; params.rightMargin = slot[1] surface.layoutParams = params } } + applySlot(navGlass, navSlot) + applySlot(miniGlass, miniSlot) + applySlot(miniContent, miniSlot) } } val peek = peekHeight() @@ -545,14 +566,18 @@ internal open class PhoneGlassSession( val sheetPosition = IntArray(2).also(sheet::getLocationInWindow) miniOffsetInSheet = miniPosition[1] - sheetPosition[1] } - val margin = (capsuleMarginPx * (1f - materialProgress)).roundToInt() + // Per-edge morph (tablet row): each side interpolates from its own + // slot edge to zero, so the pill unfolds from its bottom-right + // anchor into the full sheet while the sheet slides up. + val left = (miniMarginPx[0] * (1f - materialProgress)).roundToInt() + val right = (miniMarginPx[1] * (1f - materialProgress)).roundToInt() val top = (miniOffsetInSheet * (1f - materialProgress)).roundToInt() - val collapsedHeight = dp(GlassPolicy.MINI_HEIGHT_DP) + val collapsedHeight = dp(geometry.miniHeightDp) val height = (collapsedHeight + (sheet.height - collapsedHeight) * progress).roundToInt().coerceAtLeast(collapsedHeight) val params = glass.layoutParams as FrameLayout.LayoutParams - if (params.height != height || params.topMargin != top || params.leftMargin != margin) { + if (params.height != height || params.topMargin != top || params.leftMargin != left || params.rightMargin != right) { params.height = height; params.topMargin = top - params.leftMargin = margin; params.rightMargin = margin + params.leftMargin = left; params.rightMargin = right glass.layoutParams = params } } diff --git a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt index e5d0f1df..1be9a397 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt @@ -23,10 +23,14 @@ internal class TabletDualPaneGlassSession( override val geometry: GlassGeometry get() = GlassGeometry.Tablet - // User-tuned tablet proportion (2026-09-22, second pass): full-width - // capsules looked stretched on a tablet; both floating pills now take half - // of the host width and center — one quarter of the width as side margin. - override fun capsuleSideMarginPx(frameWidth: Int): Int = frameWidth / 4 + // User sketch (2026-09-22): both capsules share one bottom row — the nav + // pill on the left (65% of a two-thirds-wide row), the mini pill in the + // right slot (30%), a small gap between them, and equal outer whitespace + // ("留白长度一致"): row = 2W/3 centered, outer = W/6 per side. + // nav slot = [W/6, 2W/5]; mini slot = [19W/30, W/6]. + override fun capsuleMarginsPx(frameWidth: Int, mini: Boolean): IntArray = + if (mini) intArrayOf(frameWidth * 19 / 30, frameWidth / 6) + else intArrayOf(frameWidth / 6, frameWidth * 2 / 5) // The session lives only while the official tablet runs the dual-pane player; // portrait or dual-pane-off restores the native chrome through close(). diff --git a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt index 720c64dc..17e71af4 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt @@ -68,17 +68,18 @@ class TabletLiquidGlassStructuralRegressionTest { } @Test - fun `shortens the tablet capsules to half of the host width`() { + fun `lays the tablet capsules side by side in one centered row`() { val session = source("dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt") val base = source("dev/amenhancer/module/hook/PhoneGlassSession.kt") - // The phone keeps its tuned 16dp margins (capsuleSideMarginPx default); - // only the tablet form shortens both floating capsules by taking - // frameWidth/4 side margins, resynced whenever the frame width settles. - assertTrue(session.contains("frameWidth / 4")) - assertTrue(base.contains("capsuleSideMarginPx")) - // The native mini content must follow the same margins, or its artwork, - // title and playback buttons drift outside the shortened capsule. - assertTrue(base.contains("navGlass, miniGlass, miniContent")) + // Nav pill left of one centered row, mini pill in the right slot, equal + // outer whitespace of frameWidth/6 ("留白长度一致"): the tablet override + // carves [W/6, 2W/5] for the nav and [19W/30, W/6] for the mini. + assertTrue(session.contains("frameWidth * 19 / 30")) + assertTrue(session.contains("frameWidth * 2 / 5")) + // The native mini content and the morphing glass share the mini slot, and + // the expand morph interpolates each edge from its own slot edge to zero. + assertTrue(base.contains("capsuleMarginsPx")) + assertTrue(base.contains("applySlot(miniContent, miniSlot)")) } @Test diff --git a/glass/src/main/kotlin/dev/amenhancer/glass/GlassPolicy.kt b/glass/src/main/kotlin/dev/amenhancer/glass/GlassPolicy.kt index 1b2571cc..da9239b7 100644 --- a/glass/src/main/kotlin/dev/amenhancer/glass/GlassPolicy.kt +++ b/glass/src/main/kotlin/dev/amenhancer/glass/GlassPolicy.kt @@ -13,11 +13,13 @@ data class GlassGeometry( val miniHeightDp: Int = 43, val horizontalDp: Int = 16, val gapDp: Int = 8, + /** Side-by-side forms put the mini capsule beside the nav capsule in one row. */ + val sideBySide: Boolean = false, ) { companion object { val Phone = GlassGeometry() - /** Starts identical to [Phone]; diverge here when tablet geometry is verified. */ - val Tablet = GlassGeometry() + /** Tablet dual-pane row: both capsules share one 56dp row (2026-09-22 sketch). */ + val Tablet = GlassGeometry(miniHeightDp = 56, sideBySide = true) } } @@ -69,5 +71,5 @@ object GlassPolicy { bottomGapDp: Int = BOTTOM_DP, geometry: GlassGeometry = GlassGeometry.Phone, ): Int = - ((geometry.navHeightDp + bottomGapDp + if (miniVisible) geometry.miniHeightDp + geometry.gapDp else 0) * density).toInt() + bottomInset + ((geometry.navHeightDp + bottomGapDp + if (miniVisible && !geometry.sideBySide) geometry.miniHeightDp + geometry.gapDp else 0) * density).toInt() + bottomInset } diff --git a/glass/src/test/kotlin/dev/amenhancer/glass/GlassGeometryTest.kt b/glass/src/test/kotlin/dev/amenhancer/glass/GlassGeometryTest.kt index e42b84cb..d48a2096 100644 --- a/glass/src/test/kotlin/dev/amenhancer/glass/GlassGeometryTest.kt +++ b/glass/src/test/kotlin/dev/amenhancer/glass/GlassGeometryTest.kt @@ -4,14 +4,17 @@ import org.junit.Assert.* import org.junit.Test class GlassGeometryTest { - @Test fun phoneAndTabletPresetsStartIdentical() { - // sw640dp native miniplayer_height=59dp / mini_player_thumbnail_height=41dp are the - // documented fork reference; both presets deliberately share the phone capsule today. - assertEquals(GlassGeometry.Phone, GlassGeometry.Tablet) + @Test fun phoneAndTabletPresetsDivergeOnTheTabletRow() { + // The phone keeps the accepted stacked capsule; the tablet dual-pane row + // puts both capsules in one 56dp row (2026-09-22 sketch). assertEquals( GlassGeometry(navHeightDp = 56, miniHeightDp = 43, horizontalDp = 16, gapDp = 8), GlassGeometry.Phone, ) + assertEquals( + GlassGeometry(navHeightDp = 56, miniHeightDp = 56, horizontalDp = 16, gapDp = 8, sideBySide = true), + GlassGeometry.Tablet, + ) assertEquals(GlassPolicy.NAV_HEIGHT_DP, GlassGeometry.Phone.navHeightDp) assertEquals(GlassPolicy.MINI_HEIGHT_DP, GlassGeometry.Phone.miniHeightDp) assertEquals(GlassPolicy.HORIZONTAL_DP, GlassGeometry.Phone.horizontalDp) @@ -39,6 +42,14 @@ class GlassGeometryTest { assertEquals(157, GlassPolicy.occupiedHeight(1f, 8, true, bottomGapDp = 24, geometry = forked)) } + @Test fun sideBySideGeometryFoldsTheMiniIntoTheRow() { + val row = GlassGeometry(navHeightDp = 56, miniHeightDp = 56, sideBySide = true) + // The mini sits beside the nav capsule: it adds no extra occupied height. + // (56 + 16) * 2 + 24 = 168 + assertEquals(168, GlassPolicy.occupiedHeight(2f, 24, true, geometry = row)) + assertEquals(168, GlassPolicy.occupiedHeight(2f, 24, false, geometry = row)) + } + @Test fun hostFormsShareOneSeamWhitelist() { assertTrue(GlassPolicy.supports(33, 1586, "6.5.2", GlassHostForm.PhoneStacked)) assertTrue(GlassPolicy.supports(33, 1586, "6.5.2", GlassHostForm.TabletDualPane)) From 9e3f96ad6cd4dde8332946a413a7ca57abf499bb Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 23 Sep 2026 14:06:20 +0800 Subject: [PATCH 10/19] fix(glass): take over the tablet row band touch chain --- .../amenhancer/module/hook/PhoneGlassSession.kt | 16 ++++++++++++++++ .../module/hook/TabletDualPaneGlassSession.kt | 7 +++++++ .../TabletLiquidGlassStructuralRegressionTest.kt | 11 +++++++++++ 3 files changed, 34 insertions(+) diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt index d862b29d..0f13f209 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt @@ -127,6 +127,17 @@ internal open class PhoneGlassSession( return intArrayOf(side, side) } + /** Tablet-only fallback: the side-by-side mini drives its own tap-to-expand. */ + protected open fun armMiniTap(content: View) = Unit + + /** Expand driver for the armed mini tap; behavior state 3 = expanded. */ + protected fun expandPlayer() { + val behavior = playerBehavior ?: return + runCatching { + behavior.javaClass.getMethod("setState", Int::class.javaPrimitiveType!!).invoke(behavior, 3) + } + } + // Resource IDs are stable for this Activity's host APK. Keep values and Views live so // configuration changes and replaced page/player hierarchies still take effect. private val resourceIds = HashMap() @@ -156,6 +167,10 @@ internal open class PhoneGlassSession( */ protected open fun suppressNativeChromeSeams() { hideSeam(find("navigation_tabs_divider")) + // The native tab strip stays alpha-hidden but touchable across its full + // width; invisible taps must never select a native menu item, so seam + // suppression owns its visibility too (restored on close like any seam). + hideSeam(navigation) } protected fun hideSeam(view: View?) { @@ -434,6 +449,7 @@ internal open class PhoneGlassSession( } } } + miniContent?.let(::armMiniTap) listOf("player_root", "player_top_shadow", "background_layers", "motion_switcher", "player_fragments_host").mapNotNull(::find).forEach(::save) } diff --git a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt index 1be9a397..adc442c3 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt @@ -32,6 +32,13 @@ internal class TabletDualPaneGlassSession( if (mini) intArrayOf(frameWidth * 19 / 30, frameWidth / 6) else intArrayOf(frameWidth / 6, frameWidth * 2 / 5) + // The native mini tap listener is unreliable in the side-by-side row (taps + // fell through to the invisible native tab strip and switched its tabs), so + // the mini content — exactly the mini slot bounds — owns tap-to-expand. + override fun armMiniTap(content: View) { + content.setOnClickListener { expandPlayer() } + } + // The session lives only while the official tablet runs the dual-pane player; // portrait or dual-pane-off restores the native chrome through close(). override fun sessionEligible(): Boolean = diff --git a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt index 17e71af4..16f09075 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt @@ -82,6 +82,17 @@ class TabletLiquidGlassStructuralRegressionTest { assertTrue(base.contains("applySlot(miniContent, miniSlot)")) } + @Test + fun `owns the row band touch chain`() { + val session = source("dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt") + val base = source("dev/amenhancer/module/hook/PhoneGlassSession.kt") + // The invisible native tab strip must never eat row-band taps, and the + // side-by-side mini content arms its own tap-to-expand fallback. + assertTrue(base.contains("hideSeam(navigation)")) + assertTrue(session.contains("setOnClickListener { expandPlayer() }")) + assertTrue(base.contains("getMethod(\"setState\"")) + } + @Test fun `hides the compensation row while the glass toggle is on`() { val settings = source("dev/amenhancer/module/ui/EmbeddedSettingsHost.kt") From 0934ebd7b6b3939e4fd940feff48189ebf48ac2f Mon Sep 17 00:00:00 2001 From: ci Date: Sun, 27 Sep 2026 17:49:13 +0800 Subject: [PATCH 11/19] fix(glass): anchor tablet mini player touch and artwork transition --- .../amenhancer/module/hook/GlassSession.kt | 5 + .../module/hook/PhoneGlassRuntime.kt | 51 ++++++- .../module/hook/PhoneGlassSession.kt | 35 ++--- .../module/hook/TabletDualPaneGlassSession.kt | 125 +++++++++++++++++- ...bletLiquidGlassStructuralRegressionTest.kt | 13 +- .../glass/TabletGlassLayoutPolicy.kt | 49 +++++++ .../glass/TabletGlassLayoutPolicyTest.kt | 39 ++++++ 7 files changed, 290 insertions(+), 27 deletions(-) create mode 100644 glass/src/main/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicy.kt create mode 100644 glass/src/test/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicyTest.kt diff --git a/app/src/main/java/dev/amenhancer/module/hook/GlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/GlassSession.kt index 6bfe7e96..c1f716f9 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/GlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/GlassSession.kt @@ -1,6 +1,7 @@ package dev.amenhancer.module.hook import android.view.MotionEvent +import android.view.View import android.widget.FrameLayout /** @@ -20,6 +21,10 @@ internal interface GlassSession : AutoCloseable { fun peekHeight(): Int fun redirectedPadding(view: Any?): Int? fun redirectedLayerAlpha(view: Any?, alpha: Float): Float? + fun shouldPassThroughTouch(view: View, event: MotionEvent): Boolean + fun shouldBypassPlayerIntercept(event: MotionEvent): Boolean + /** Returns null for normal dispatch, or the native mini player's handled result. */ + fun dispatchCollapsedMiniTouch(view: View, event: MotionEvent): Boolean? fun observeTouch(event: MotionEvent) fun foreground(active: Boolean) override fun close() diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt index a84ad82e..c938dd95 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt @@ -85,6 +85,18 @@ internal object PhoneGlassRuntime { if (hooksInstalled) return check(!hooksAttempted) { "Glass hook installation previously failed; restart the host to retry" } hooksAttempted = true + val behavior = loader.loadClass("com.apple.android.music.player.PlayerBottomSheetBehavior") + // The host obfuscates this override's name; the Coordinator/View/MotionEvent + // signature is the verified interception seam on the supported builds. + val intercept = behavior.declaredMethods.single { candidate -> + val parameters = candidate.parameterTypes + candidate.returnType == Boolean::class.javaPrimitiveType && + parameters.size == 3 && + parameters[0].name == "androidx.coordinatorlayout.widget.CoordinatorLayout" && + View::class.java.isAssignableFrom(parameters[1]) && + parameters[2] == MotionEvent::class.java + }.apply { isAccessible = true } + val peek = method(behavior, "F", Int::class.javaPrimitiveType!!, Boolean::class.javaPrimitiveType!!) // The stacked and flat holders each drive their own slide contract; the outer // activity reflection resolves both holder shapes. for (holderName in listOf("StackedBottomNavigationHolder", "FlatBottomNavigationHolder")) { @@ -99,11 +111,26 @@ internal object PhoneGlassRuntime { ModernXposedRuntime.hookMethod(ViewGroup::class.java.getDeclaredMethod("dispatchTouchEvent", MotionEvent::class.java), object : ModernMethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { val root = param.thisObject as? View ?: return - sessions.values.firstOrNull { it.miniRoot === root }?.observeTouch(param.args[0] as MotionEvent) + val event = param.args[0] as MotionEvent + if (sessions.values.any { it.shouldPassThroughTouch(root, event) }) { + param.result = false + return + } + sessions.values.firstNotNullOfOrNull { it.dispatchCollapsedMiniTouch(root, event) }?.let { + param.result = it + return + } + sessions.values.firstOrNull { it.miniRoot === root }?.observeTouch(event) + } + }) + ModernXposedRuntime.hookMethod(intercept, object : ModernMethodHook() { + override fun beforeHookedMethod(param: MethodHookParam) { + val event = param.args.getOrNull(2) as? MotionEvent ?: return + if (sessions.values.any { it.playerBehavior === param.thisObject && it.shouldBypassPlayerIntercept(event) }) { + param.result = false + } } }) - val behavior = loader.loadClass("com.apple.android.music.player.PlayerBottomSheetBehavior") - val peek = method(behavior, "F", Int::class.javaPrimitiveType!!, Boolean::class.javaPrimitiveType!!) ModernXposedRuntime.hookMethod(peek, object : ModernMethodHook() { override fun beforeHookedMethod(param: MethodHookParam) { sessions.values.firstOrNull { it.playerBehavior === param.thisObject }?.let { @@ -112,6 +139,24 @@ internal object PhoneGlassRuntime { } } }) + // Apple's artwork callback computes the cover transform from the mini + // thumbnail and then writes it each slide frame. Apply the tablet-only + // source alignment after that write, leaving its scale and the glass + // transition untouched. The callback is optional on other host builds. + runCatching { + val callback = loader.loadClass("com.apple.android.music.player.fragment.v0\$k") + val artworkField = callback.getDeclaredField("a").apply { isAccessible = true } + val slideMethod = callback.getDeclaredMethod("c", Float::class.javaPrimitiveType!!) + ModernXposedRuntime.hookMethod(slideMethod, object : ModernMethodHook() { + override fun afterHookedMethod(param: MethodHookParam) { + val artwork = artworkField.get(param.thisObject) as? View ?: return + val progress = (param.args[0] as? Number)?.toFloat() ?: return + sessions.values.forEach { session -> + (session as? TabletDualPaneGlassSession)?.alignNativeArtworkStart(artwork, progress) + } + } + }) + } // Apple's scrolling behavior reserves bottom padding on the content host. // Redirect it before setPadding rather than fighting it with another layout every frame. ModernXposedRuntime.hookMethod(View::class.java.getDeclaredMethod("setPadding", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType), object : ModernMethodHook() { diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt index 0f13f209..b85f8196 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt @@ -60,9 +60,9 @@ internal open class PhoneGlassSession( private var navigation: View? = null private var source: ViewGroup? = null private var backdrop: ViewBackdrop? = null - private var navGlass: GlassHostView? = null + protected var navGlass: GlassHostView? = null private var navScrim: GlassHostView? = null - private var miniGlass: GlassHostView? = null + protected var miniGlass: GlassHostView? = null final override var miniRoot: FrameLayout? = null private set private var miniContent: View? = null @@ -72,6 +72,7 @@ internal open class PhoneGlassSession( private var closed = false private var failureScheduled = false protected var hostRoot: View? = null + protected var playerSheet: View? = null final override var activated = false private set private var tabs by mutableStateOf(emptyList()) @@ -82,6 +83,7 @@ internal open class PhoneGlassSession( private var menuKey: List = emptyList() private val input = NativeButtonInput() private var slide = 0f + protected val isCollapsed: Boolean get() = slide <= 0.001f private var glassExpansion by androidx.compose.runtime.mutableFloatStateOf(0f) private var miniOffsetInSheet = 0 private var navMarginPx = intArrayOf(0, 0) @@ -117,6 +119,8 @@ internal open class PhoneGlassSession( /** Capsule geometry shared by every occupied-height consumer; a diverging form overrides this. */ protected open val geometry: GlassGeometry get() = GlassGeometry.Phone + protected open fun playerFragmentsAlphaFactor(progress: Float, materialProgress: Float): Float = materialProgress + /** * Horizontal slot of a floating capsule as [left, right] margins. The phone * keeps the tuned symmetric margins; the tablet row carves asymmetric slots @@ -127,17 +131,6 @@ internal open class PhoneGlassSession( return intArrayOf(side, side) } - /** Tablet-only fallback: the side-by-side mini drives its own tap-to-expand. */ - protected open fun armMiniTap(content: View) = Unit - - /** Expand driver for the armed mini tap; behavior state 3 = expanded. */ - protected fun expandPlayer() { - val behavior = playerBehavior ?: return - runCatching { - behavior.javaClass.getMethod("setState", Int::class.javaPrimitiveType!!).invoke(behavior, 3) - } - } - // Resource IDs are stable for this Activity's host APK. Keep values and Views live so // configuration changes and replaced page/player hierarchies still take effect. private val resourceIds = HashMap() @@ -278,6 +271,7 @@ internal open class PhoneGlassSession( // The native mini container disappears early in the opening animation. // Keep the material behind the whole sheet, independent of that container. val surfaceParent = find("player_sheet_container") as? FrameLayout ?: root + playerSheet = surfaceParent surfaceParent.addView(glass, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(geometry.miniHeightDp), Gravity.TOP).apply { val slot = capsuleMarginsPx(0, mini = true) leftMargin = slot[0]; rightMargin = slot[1] @@ -390,6 +384,7 @@ internal open class PhoneGlassSession( // Read the laid-out native state before changing peek height or hiding any layer. val sheet = find("player_sheet_container") ?: return if (!sheet.isLaidOut) return + playerSheet = sheet val behavior = checkNotNull(playerBehavior) val base = activity.classLoader.loadClass("com.google.android.material.bottomsheet.BottomSheetBehavior") val state = base.getDeclaredField("G").apply { isAccessible = true }.getInt(behavior) @@ -449,7 +444,6 @@ internal open class PhoneGlassSession( } } } - miniContent?.let(::armMiniTap) listOf("player_root", "player_top_shadow", "background_layers", "motion_switcher", "player_fragments_host").mapNotNull(::find).forEach(::save) } @@ -604,9 +598,12 @@ internal open class PhoneGlassSession( val desired = if (progress == 0f) null else original.outlineProvider if (v.outlineProvider !== desired) v.outlineProvider = desired } - listOf("player_top_shadow", "background_layers", "player_fragments_host").mapNotNull(::find).forEach { v -> + listOf("player_top_shadow", "background_layers").mapNotNull(::find).forEach { v -> applyLayerAlpha(v, materialProgress) } + find("player_fragments_host")?.let { v -> + applyLayerAlpha(v, playerFragmentsAlphaFactor(progress, materialProgress)) + } // The motion subtree includes rectangular legibility/blur overlays and can // still have thumbnail-sized bounds early in the native transition. Reveal // it only after the glass has faded and the opaque player background is back. @@ -637,6 +634,13 @@ internal open class PhoneGlassSession( if (underlap) 0 else if (navFrame?.isShown == true) GlassPolicy.occupiedHeight(density, bottomInset, miniVisible, bottomGapDp, geometry) else 0 } else null + // Only the tablet form narrows the full-width host touch surfaces. + override fun shouldPassThroughTouch(view: View, event: MotionEvent): Boolean = false + + override fun shouldBypassPlayerIntercept(event: MotionEvent): Boolean = false + + override fun dispatchCollapsedMiniTouch(view: View, event: MotionEvent): Boolean? = null + private fun miniGlassPosition(event: MotionEvent): Pair? { val glass = miniGlass ?: return null if (glass.width <= 0 || glass.height <= 0) return null @@ -679,6 +683,7 @@ internal open class PhoneGlassSession( if (closed) return closed = true activated = false + playerSheet = null attachHandler.removeCallbacks(retryAttach) retryPending = false observer?.takeIf { it.isAlive }?.removeOnPreDrawListener(this) diff --git a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt index adc442c3..43bb7a43 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt @@ -1,10 +1,15 @@ package dev.amenhancer.module.hook import android.app.Activity +import android.view.MotionEvent import android.view.View +import android.widget.FrameLayout import androidx.annotation.RequiresApi +import dev.amenhancer.glass.GlassCapsuleBounds import dev.amenhancer.glass.GlassGeometry import dev.amenhancer.glass.GlassPolicy +import dev.amenhancer.glass.TabletGlassLayoutPolicy +import dev.amenhancer.glass.TabletGlassGestureGate import dev.amenhancer.module.config.TargetConfigClient import kotlin.math.exp @@ -21,8 +26,22 @@ internal class TabletDualPaneGlassSession( failure: (Throwable) -> Unit, ) : PhoneGlassSession(activity, config, failure) { + private val touchGate = TabletGlassGestureGate() + private var miniPressDownTime: Long? = null + private var redirectedMiniDownTime: Long? = null + private var redirectedMiniTarget: FrameLayout? = null + private var artworkAnchorView: View? = null + private var artworkStartOffsetY: Float? = null + + private data class CapsuleHit(val any: Boolean, val mini: Boolean) + override val geometry: GlassGeometry get() = GlassGeometry.Tablet + // Reveal the native cover at the first slide only after its alignment hook + // has identified this player's artwork. Other host builds keep the stock fade. + protected override fun playerFragmentsAlphaFactor(progress: Float, materialProgress: Float): Float = + if (progress > 0f && artworkAnchorView === find("fullplayerSongImage")) 1f else materialProgress + // User sketch (2026-09-22): both capsules share one bottom row — the nav // pill on the left (65% of a two-thirds-wide row), the mini pill in the // right slot (30%), a small gap between them, and equal outer whitespace @@ -32,11 +51,107 @@ internal class TabletDualPaneGlassSession( if (mini) intArrayOf(frameWidth * 19 / 30, frameWidth / 6) else intArrayOf(frameWidth / 6, frameWidth * 2 / 5) - // The native mini tap listener is unreliable in the side-by-side row (taps - // fell through to the invisible native tab strip and switched its tabs), so - // the mini content — exactly the mini slot bounds — owns tap-to-expand. - override fun armMiniTap(content: View) { - content.setOnClickListener { expandPlayer() } + /** Keep Apple's scaled artwork inside the opening mini glass. */ + internal fun alignNativeArtworkStart(artwork: View, slide: Float) { + if (!activated || !slide.isFinite() || artwork !== find("fullplayerSongImage")) return + val container = artwork.parent as? View ?: return + if (container.id != resourceId("artwork_container", "id")) return + if (artworkAnchorView !== artwork) { + artworkAnchorView = artwork + artworkStartOffsetY = null + } + val progress = slide.coerceIn(0f, 1f) + val miniCover = miniRoot?.findViewById(resourceId("video_surface_container", "id")) + if (progress <= 0.001f && artwork.scaleY < 0.2f) { + if (miniCover != null && miniCover.width > 0 && miniCover.height > 0) { + val source = IntArray(2).also(miniCover::getLocationOnScreen) + val target = IntArray(2).also(artwork::getLocationOnScreen) + artworkStartOffsetY = (source[1] - target[1]).toFloat() + } + } + // offsetDescendantRectToMyCoords in the native callback omits the + // dual-pane artwork_container's visual translation. This shift restores + // the thumbnail's actual screen origin and fades out at the full view. + val sourceCorrection = artworkStartOffsetY ?: -container.translationY + artwork.translationY += sourceCorrection * (1f - progress) + if (progress > 0f && miniCover != null && miniCover.width > 0 && miniCover.height > 0) { + val source = IntArray(2).also(miniCover::getLocationOnScreen) + val target = IntArray(2).also(artwork::getLocationOnScreen) + // Apple's full cover can run above the glass while the sheet is still + // opening. Its native scale and horizontal motion remain untouched. + if (target[1] < source[1]) artwork.translationY += (source[1] - target[1]).toFloat() + } + } + + /** Both native touch owners are full-width; only the rendered capsules accept a down. */ + override fun shouldPassThroughTouch(view: View, event: MotionEvent): Boolean { + if (view !== hostRoot && view !== playerSheet && view !== miniRoot) return false + return passesThrough(event) + } + + override fun shouldBypassPlayerIntercept(event: MotionEvent): Boolean = passesThrough(event) + + override fun dispatchCollapsedMiniTouch(view: View, event: MotionEvent): Boolean? { + if (view.id != resourceId("player_root", "id") || view !== find("player_root")) return null + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + redirectedMiniTarget = miniRoot?.takeIf { + activated && isCollapsed && it.isShown && capsuleHit(event)?.mini == true + } + redirectedMiniDownTime = event.downTime.takeIf { redirectedMiniTarget != null } + } + val target = redirectedMiniTarget?.takeIf { redirectedMiniDownTime == event.downTime } ?: return null + val location = IntArray(2).also(target::getLocationOnScreen) + val forwarded = MotionEvent.obtain(event) + forwarded.setLocation(event.rawX - location[0], event.rawY - location[1]) + return try { + target.dispatchTouchEvent(forwarded) + } finally { + forwarded.recycle() + if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { + redirectedMiniTarget = null + redirectedMiniDownTime = null + } + } + } + + private fun passesThrough(event: MotionEvent): Boolean { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + val accepted = !activated || !isCollapsed || capsuleHit(event)?.any != false + return touchGate.start(event.downTime, hitCapsule = accepted) + } + // Keep the initial target for the whole gesture, including a move into a capsule. + // The next DOWN resets this latch; multiple hooks may see the same UP/CANCEL. + return touchGate.isPassedThrough(event.downTime) + } + + override fun observeTouch(event: MotionEvent) { + if (event.actionMasked == MotionEvent.ACTION_DOWN) { + miniPressDownTime = event.downTime.takeIf { capsuleHit(event)?.mini == true } + } + if (miniPressDownTime == event.downTime) super.observeTouch(event) + if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) { + miniPressDownTime = null + } + } + + private fun capsuleHit(event: MotionEvent): CapsuleHit? { + val frame = navFrame ?: return null + val navigation = navGlass?.takeIf { it.isShown && it.width > 0 && it.height > 0 } ?: return null + val mini = if (miniVisible) miniGlass?.takeIf { it.isShown && it.width > 0 && it.height > 0 } ?: return null else null + val origin = IntArray(2).also(frame::getLocationOnScreen) + val x = event.rawX - origin[0] + val y = event.rawY - origin[1] + fun bounds(view: View): GlassCapsuleBounds { + val location = IntArray(2).also(view::getLocationOnScreen) + val left = (location[0] - origin[0]).toFloat() + val top = (location[1] - origin[1]).toFloat() + return GlassCapsuleBounds(left, top, left + view.width, top + view.height) + } + val miniBounds = mini?.let(::bounds) + return CapsuleHit( + any = TabletGlassLayoutPolicy.containsEither(x, y, bounds(navigation), miniBounds), + mini = miniBounds?.let { TabletGlassLayoutPolicy.contains(x, y, it) } == true, + ) } // The session lives only while the official tablet runs the dual-pane player; diff --git a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt index 16f09075..804a6953 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt @@ -86,11 +86,16 @@ class TabletLiquidGlassStructuralRegressionTest { fun `owns the row band touch chain`() { val session = source("dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt") val base = source("dev/amenhancer/module/hook/PhoneGlassSession.kt") - // The invisible native tab strip must never eat row-band taps, and the - // side-by-side mini content arms its own tap-to-expand fallback. + val runtime = source("dev/amenhancer/module/hook/PhoneGlassRuntime.kt") + // The invisible native tabs stay gone, while the full-width roots and + // player behavior pass blank-area gestures to the underlying page. assertTrue(base.contains("hideSeam(navigation)")) - assertTrue(session.contains("setOnClickListener { expandPlayer() }")) - assertTrue(base.contains("getMethod(\"setState\"")) + assertTrue(session.contains("TabletGlassLayoutPolicy.containsEither")) + assertTrue(runtime.contains("it.shouldPassThroughTouch(root, event)")) + assertTrue(runtime.contains("it.shouldBypassPlayerIntercept(event)")) + // Apple binds both native click and long-click to mini_player_touch_panel. + assertTrue(!session.contains("setOnClickListener")) + assertTrue(!base.contains("getMethod(\"setState\"")) } @Test diff --git a/glass/src/main/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicy.kt b/glass/src/main/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicy.kt new file mode 100644 index 00000000..280aa4d2 --- /dev/null +++ b/glass/src/main/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicy.kt @@ -0,0 +1,49 @@ +package dev.amenhancer.glass + +import kotlin.math.min + +/** Bounds of a rendered capsule in the bottom bar's local coordinate space. */ +data class GlassCapsuleBounds( + val left: Float, + val top: Float, + val right: Float, + val bottom: Float, +) + +/** The tablet's touch region follows the visible capsules, including their round ends. */ +object TabletGlassLayoutPolicy { + fun contains(x: Float, y: Float, capsule: GlassCapsuleBounds): Boolean { + if (x < capsule.left || x >= capsule.right || y < capsule.top || y >= capsule.bottom) return false + val radius = min(capsule.right - capsule.left, capsule.bottom - capsule.top) / 2f + if (radius <= 0f) return false + val centerX = x.coerceIn(capsule.left + radius, capsule.right - radius) + val centerY = y.coerceIn(capsule.top + radius, capsule.bottom - radius) + val dx = x - centerX + val dy = y - centerY + return dx * dx + dy * dy <= radius * radius + } + + fun containsEither( + x: Float, + y: Float, + navigation: GlassCapsuleBounds, + miniPlayer: GlassCapsuleBounds?, + ): Boolean = contains(x, y, navigation) || + (miniPlayer != null && contains(x, y, miniPlayer)) +} + +/** A gesture keeps the owner chosen by its first DOWN, even after it moves. */ +class TabletGlassGestureGate { + private var currentDownTime: Long? = null + private var passedThroughDownTime: Long? = null + + fun start(downTime: Long, hitCapsule: Boolean): Boolean { + if (currentDownTime != downTime) { + currentDownTime = downTime + passedThroughDownTime = downTime.takeUnless { hitCapsule } + } + return isPassedThrough(downTime) + } + + fun isPassedThrough(downTime: Long): Boolean = passedThroughDownTime == downTime +} diff --git a/glass/src/test/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicyTest.kt b/glass/src/test/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicyTest.kt new file mode 100644 index 00000000..a9cee7ed --- /dev/null +++ b/glass/src/test/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicyTest.kt @@ -0,0 +1,39 @@ +package dev.amenhancer.glass + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TabletGlassLayoutPolicyTest { + private val navigation = GlassCapsuleBounds(100f, 20f, 360f, 76f) + private val mini = GlassCapsuleBounds(380f, 20f, 500f, 76f) + + @Test fun `only the two rounded capsules own touches`() { + assertTrue(TabletGlassLayoutPolicy.containsEither(120f, 48f, navigation, mini)) + assertTrue(TabletGlassLayoutPolicy.containsEither(480f, 48f, navigation, mini)) + assertFalse(TabletGlassLayoutPolicy.containsEither(370f, 48f, navigation, mini)) + assertFalse(TabletGlassLayoutPolicy.containsEither(90f, 48f, navigation, mini)) + assertFalse(TabletGlassLayoutPolicy.containsEither(450f, 15f, navigation, mini)) + } + + @Test fun `capsule bounding-box corners are not touchable`() { + assertFalse(TabletGlassLayoutPolicy.contains(101f, 21f, navigation)) + assertFalse(TabletGlassLayoutPolicy.contains(499f, 21f, mini)) + assertTrue(TabletGlassLayoutPolicy.contains(100f, 48f, navigation)) + assertTrue(TabletGlassLayoutPolicy.contains(499f, 48f, mini)) + } + + @Test fun `missing mini player leaves no phantom hit region`() { + assertFalse(TabletGlassLayoutPolicy.containsEither(440f, 48f, navigation, null)) + } + + @Test fun `gesture owner follows the first down`() { + val gate = TabletGlassGestureGate() + assertTrue(gate.start(10L, hitCapsule = false)) + assertTrue(gate.start(10L, hitCapsule = true)) // A second hook sees the same DOWN. + assertTrue(gate.isPassedThrough(10L)) + assertFalse(gate.start(20L, hitCapsule = true)) + assertFalse(gate.isPassedThrough(20L)) + assertFalse(gate.isPassedThrough(10L)) + } +} From 38316665d57e46e3abbcec25700d9100fc85ee4e Mon Sep 17 00:00:00 2001 From: ci Date: Sun, 27 Sep 2026 18:43:12 +0800 Subject: [PATCH 12/19] perf(glass): share hidden backdrop optimizations across phone and tablet --- .../module/hook/PhoneGlassSession.kt | 102 +++++++++++++----- .../module/hook/TabletDualPaneGlassSession.kt | 8 +- .../kyant/backdrop/backdrops/ViewBackdrop.kt | 28 ++++- 3 files changed, 111 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt index b85f8196..3bd7a21c 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt @@ -55,6 +55,10 @@ internal open class PhoneGlassSession( ) : GlassSession, ViewTreeObserver.OnPreDrawListener { private val states = IdentityHashMap() private val layerAlphas = IdentityHashMap() + private val visibleGlassRect = android.graphics.Rect() + private val visibleGlassLocation = IntArray(2) + private val windowLocation = IntArray(2) + private var glassConsumersVisible = false private var writingLayerAlpha = false protected var navFrame: FrameLayout? = null private var navigation: View? = null @@ -367,11 +371,19 @@ internal open class PhoneGlassSession( navigation?.alpha = if (menuReady) 0f else 1f updateGeometry() val sourceNeedsLayout = updateUnderlap() - updateTransition() + val transitionNeedsLayout = updateTransition() // Insets can be reapplied when the native player finishes collapsing. // setLayoutParams only schedules layout: do not expose the old, shorter // content bounds (and window background beneath them) in this frame. - if (sourceNeedsLayout) return false + if (sourceNeedsLayout || transitionNeedsLayout || + (glassConsumersVisible && backdrop?.ready == false && canRefreshBackdrop())) return false + } else if (backdrop?.ready == true) { + // Keep the initial capture for activation, but stop recording while + // the menu has not produced a visible glass surface yet. + backdrop?.setCaptureEnabled( + backdropConsumerVisible(navGlass) || backdropConsumerVisible(navScrim) || + backdropConsumerVisible(miniGlass), + ) } } catch (error: Throwable) { scheduleFailure(error) } return true @@ -553,7 +565,7 @@ internal open class PhoneGlassSession( return sourceNeedsLayout } - private fun updateTransition() { + private fun updateTransition(): Boolean { suppressNativeChromeSeams() navFrame?.background = null // Keep Z ordering (also used for touch dispatch); remove only the old @@ -568,30 +580,40 @@ internal open class PhoneGlassSession( } val materialProgress = blend(0f, 0.35f) glassExpansion = materialProgress + val miniAlpha = if (!miniVisible && progress == 0f) 0f else 1f - blend(0.35f, 0.6f) + val hideTransparentMiniGlass = miniAlpha <= 0f + var revivedMiniGlass = false miniGlass?.let { glass -> - val sheet = glass.parent as? FrameLayout - if (sheet != null && sheet !== miniRoot) { - if (miniVisible && progress == 0f) { - val miniPosition = IntArray(2).also { miniRoot?.getLocationInWindow(it) } - val sheetPosition = IntArray(2).also(sheet::getLocationInWindow) - miniOffsetInSheet = miniPosition[1] - sheetPosition[1] - } - // Per-edge morph (tablet row): each side interpolates from its own - // slot edge to zero, so the pill unfolds from its bottom-right - // anchor into the full sheet while the sheet slides up. - val left = (miniMarginPx[0] * (1f - materialProgress)).roundToInt() - val right = (miniMarginPx[1] * (1f - materialProgress)).roundToInt() - val top = (miniOffsetInSheet * (1f - materialProgress)).roundToInt() - val collapsedHeight = dp(geometry.miniHeightDp) - val height = (collapsedHeight + (sheet.height - collapsedHeight) * progress).roundToInt().coerceAtLeast(collapsedHeight) - val params = glass.layoutParams as FrameLayout.LayoutParams - if (params.height != height || params.topMargin != top || params.leftMargin != left || params.rightMargin != right) { - params.height = height; params.topMargin = top - params.leftMargin = left; params.rightMargin = right - glass.layoutParams = params + if (!hideTransparentMiniGlass) { + val sheet = glass.parent as? FrameLayout + if (sheet != null && sheet !== miniRoot) { + if (miniVisible && progress == 0f) { + val miniPosition = IntArray(2).also { miniRoot?.getLocationInWindow(it) } + val sheetPosition = IntArray(2).also(sheet::getLocationInWindow) + miniOffsetInSheet = miniPosition[1] - sheetPosition[1] + } + // Per-edge morph (tablet row): each side interpolates from its own + // slot edge to zero, so the pill unfolds from its bottom-right + // anchor into the full sheet while the sheet slides up. + val left = (miniMarginPx[0] * (1f - materialProgress)).roundToInt() + val right = (miniMarginPx[1] * (1f - materialProgress)).roundToInt() + val top = (miniOffsetInSheet * (1f - materialProgress)).roundToInt() + val collapsedHeight = dp(geometry.miniHeightDp) + val height = (collapsedHeight + (sheet.height - collapsedHeight) * progress).roundToInt().coerceAtLeast(collapsedHeight) + val params = glass.layoutParams as FrameLayout.LayoutParams + if (params.height != height || params.topMargin != top || params.leftMargin != left || params.rightMargin != right) { + params.height = height; params.topMargin = top + params.leftMargin = left; params.rightMargin = right + glass.layoutParams = params + } } } - glass.alpha = if (!miniVisible && progress == 0f) 0f else 1f - blend(0.35f, 0.6f) + if (glass.alpha != miniAlpha) glass.alpha = miniAlpha + val visibility = if (hideTransparentMiniGlass) View.GONE else View.VISIBLE + if (glass.visibility != visibility) { + revivedMiniGlass = visibility == View.VISIBLE + glass.visibility = visibility + } } find("player_sheet_container")?.let { v -> val original = save(v) @@ -611,8 +633,40 @@ internal open class PhoneGlassSession( applyLayerAlpha(v, blend(0.6f, 0.85f)) } find("player_root")?.background = if (materialProgress < 1f) null else states[find("player_root")]?.background + // The flat tablet row is parked offscreen at 60%; the stacked phone row + // moves under native control, so test its actual window bounds instead. + val consumerVisible = !(geometry.sideBySide && progress >= 0.6f) && + ((revivedMiniGlass && miniAlpha > 0f) || + backdropConsumerVisible(navGlass) || backdropConsumerVisible(navScrim) || + backdropConsumerVisible(miniGlass)) + glassConsumersVisible = consumerVisible + val captureResumed = backdrop?.setCaptureEnabled(consumerVisible) == true + return revivedMiniGlass || captureResumed } + private fun backdropConsumerVisible(view: View?): Boolean { + if (view == null || !view.isAttachedToWindow || !view.isShown || view.width <= 0 || view.height <= 0) return false + var ancestor: View? = view + while (ancestor != null) { + if (ancestor.alpha <= 0f) return false + ancestor = ancestor.parent as? View + } + if (!view.getGlobalVisibleRect(visibleGlassRect)) return false + // clipChildren=false can report an offscreen row as globally visible. + // Its translated screen bounds must still intersect this window. + view.getLocationOnScreen(visibleGlassLocation) + val window = activity.window.decorView + window.getLocationOnScreen(windowLocation) + return visibleGlassLocation[0] < windowLocation[0] + window.width && + visibleGlassLocation[0] + view.width > windowLocation[0] && + visibleGlassLocation[1] < windowLocation[1] + window.height && + visibleGlassLocation[1] + view.height > windowLocation[1] + } + + private fun canRefreshBackdrop(): Boolean = source?.let { + it.isAttachedToWindow && it.width > 0 && it.height > 0 + } == true + override fun onSlide(progress: Float) { slide = progress.coerceIn(0f, 1f) } override fun redirectedLayerAlpha(view: Any?, alpha: Float): Float? { diff --git a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt index 43bb7a43..f79e07b1 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt @@ -11,6 +11,7 @@ import dev.amenhancer.glass.GlassPolicy import dev.amenhancer.glass.TabletGlassLayoutPolicy import dev.amenhancer.glass.TabletGlassGestureGate import dev.amenhancer.module.config.TargetConfigClient +import kotlin.math.abs import kotlin.math.exp /** @@ -172,7 +173,12 @@ internal class TabletDualPaneGlassSession( override fun driveNavFrameExit(progress: Float) { val frame = navFrame ?: return val extent = GlassPolicy.occupiedHeight(density, bottomInset, miniVisible, bottomGapDp, geometry) - frame.translationY = (1f - exp(-20f * progress)) * extent + // Past the glass fade the exponential tail moves by less than a pixel. + // Park the row exactly offscreen and avoid subpixel invalidations. + val target = if (progress >= 0.6f) extent.toFloat() else (1f - exp(-20f * progress)) * extent + if (progress == 0f || progress >= 0.6f || progress < 0.35f || abs(frame.translationY - target) >= 0.5f) { + if (frame.translationY != target) frame.translationY = target + } } // The dual-pane boundary sync mutes its own writes while the glass owns the geometry. diff --git a/backdrop/src/androidMain/kotlin/com/kyant/backdrop/backdrops/ViewBackdrop.kt b/backdrop/src/androidMain/kotlin/com/kyant/backdrop/backdrops/ViewBackdrop.kt index 42a9f448..8e6d4837 100644 --- a/backdrop/src/androidMain/kotlin/com/kyant/backdrop/backdrops/ViewBackdrop.kt +++ b/backdrop/src/androidMain/kotlin/com/kyant/backdrop/backdrops/ViewBackdrop.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.unit.Density import com.kyant.backdrop.Backdrop +import kotlin.math.abs /** AM++ Android View bridge. The source must NOT contain any consumer of this backdrop. */ @RequiresApi(Build.VERSION_CODES.TIRAMISU) @@ -44,6 +45,7 @@ class ViewBackdrop( private var observer: ViewTreeObserver? = null private var recording = false private var closed = false + private var captureEnabled = true var ready: Boolean = false private set var recordings: Long = 0 @@ -57,13 +59,24 @@ class ViewBackdrop( observer = source.viewTreeObserver.also { it.addOnPreDrawListener(this) } } + /** Suspend source recording while no backdrop consumer is visible. */ + fun setCaptureEnabled(enabled: Boolean): Boolean { + if (closed || captureEnabled == enabled) return false + captureEnabled = enabled + if (enabled) { + ready = false + source.postInvalidateOnAnimation() + } + return enabled // The caller can defer this draw until the fresh capture. + } + override fun onPreDraw(): Boolean { - if (closed || recording || !source.isAttachedToWindow || source.width == 0 || source.height == 0) return true + if (closed || !captureEnabled || recording || !source.isAttachedToWindow || source.width == 0 || source.height == 0) return true try { check(source.isHardwareAccelerated) { "Hardware accelerated window required" } updateSourceMatrix() sourceToWindow.getValues(matrixValues) - val moved = !matrixValues.contentEquals(previousMatrix) + val moved = hasSignificantMatrixChange() if (!ready || source.isDirty || moved || node.width != source.width || node.height != source.height) { recording = true node.setPosition(0, 0, source.width, source.height) @@ -87,6 +100,17 @@ class ViewBackdrop( return true } + // Compare with the last recorded matrix so subpixel movement accumulates + // until it crosses the threshold; content changes still trigger a capture. + private fun hasSignificantMatrixChange(): Boolean = matrixValues.indices.any { index -> + if (index == Matrix.MTRANS_X || index == Matrix.MTRANS_Y) { + val delta = abs(matrixValues[index] - previousMatrix[index]) + !delta.isFinite() || delta >= 0.5f + } else { + matrixValues[index] != previousMatrix[index] + } + } + /** Transparent Compose scenes must include the window underneath them. Otherwise * blurred alpha blends over the still-sharp scene, defeating the blur. */ private fun drawWindowBackground(canvas: android.graphics.Canvas) { From fa141dd43a53fdd676e9a6701e7cc81b967bdde6 Mon Sep 17 00:00:00 2001 From: ci Date: Sun, 27 Sep 2026 19:15:27 +0800 Subject: [PATCH 13/19] fix(glass): layer tablet bottom scrim beneath both capsules --- .../module/hook/PhoneGlassSession.kt | 9 ++++++-- .../module/hook/TabletDualPaneGlassSession.kt | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt index 3bd7a21c..506cecd8 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt @@ -65,7 +65,7 @@ internal open class PhoneGlassSession( private var source: ViewGroup? = null private var backdrop: ViewBackdrop? = null protected var navGlass: GlassHostView? = null - private var navScrim: GlassHostView? = null + protected var navScrim: GlassHostView? = null protected var miniGlass: GlassHostView? = null final override var miniRoot: FrameLayout? = null private set @@ -201,6 +201,11 @@ internal open class PhoneGlassSession( /** Capsule exit driver; the phone host translates the frame from its own holder. */ protected open fun driveNavFrameExit(progress: Float) = Unit + /** Phone keeps its bottom fade inside the native tabs frame. */ + protected open fun attachNavigationScrim(frame: FrameLayout, scrim: GlassHostView) { + frame.addView(scrim, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + } + /** Chrome ownership hand-off; only the dual-pane session arbitrates ownership. */ protected open fun onGlassOwnership(root: View?) = Unit @@ -244,7 +249,7 @@ internal open class PhoneGlassSession( scrim.alpha = 0f scrim.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS scrim.content { HostConfiguration { BottomScrim(bg) } } - frame.addView(scrim, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + attachNavigationScrim(frame, scrim) val glass = GlassHostView(moduleContext()).also { navGlass = it } glass.alpha = 0f glass.content { HostConfiguration { GlassNavigation(tabs, selectedId, accent, foreground, bg, ::selectTab, panelBlur = navBlurDp.dp) } } diff --git a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt index f79e07b1..2b0db16d 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt @@ -3,10 +3,12 @@ package dev.amenhancer.module.hook import android.app.Activity import android.view.MotionEvent import android.view.View +import android.view.ViewGroup import android.widget.FrameLayout import androidx.annotation.RequiresApi import dev.amenhancer.glass.GlassCapsuleBounds import dev.amenhancer.glass.GlassGeometry +import dev.amenhancer.glass.GlassHostView import dev.amenhancer.glass.GlassPolicy import dev.amenhancer.glass.TabletGlassLayoutPolicy import dev.amenhancer.glass.TabletGlassGestureGate @@ -167,6 +169,17 @@ internal class TabletDualPaneGlassSession( // The flat holder reserves miniplayer_height only (no navigation_tabs_height). override fun nativePeekBaseline(): Int = bottomInset + dimen("miniplayer_height") + /** The elevated tabs frame sits above player_container. Keep its full-width fade + * below both capsules so it cannot wash over the mini player's glass. */ + override fun attachNavigationScrim(frame: FrameLayout, scrim: GlassHostView) { + val container = find("player_container") as? ViewGroup + ?: return super.attachNavigationScrim(frame, scrim) + val height = frame.height.takeIf { it > 0 } + ?: frame.layoutParams?.height?.takeIf { it > 0 } + ?: GlassPolicy.occupiedHeight(density, bottomInset, miniVisible, bottomGapDp, geometry) + container.addView(scrim, 0, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height)) + } + // The flat holder never translates the tabs frame, so the capsule exit is driven here // with the phone StackedBottomNavigationHolder.c exp(-20t) curve over the whole glass // occupied height (navigation capsule + mini); slide back to 0 parks the capsule again. @@ -179,6 +192,14 @@ internal class TabletDualPaneGlassSession( if (progress == 0f || progress >= 0.6f || progress < 0.35f || abs(frame.translationY - target) >= 0.5f) { if (frame.translationY != target) frame.translationY = target } + val scrim = navScrim ?: return + val container = scrim.parent as? ViewGroup ?: return + if (frame.height > 0 && scrim.layoutParams.height != frame.height) { + scrim.layoutParams = scrim.layoutParams.apply { height = frame.height } + } + val baseOffset = frame.top - (container.top + scrim.top) + val scrimShift = frame.translationY + baseOffset + if (scrim.translationY != scrimShift) scrim.translationY = scrimShift } // The dual-pane boundary sync mutes its own writes while the glass owns the geometry. From eaae5811a08e114008f58ca05e39f2026c56b33d Mon Sep 17 00:00:00 2001 From: ci Date: Sun, 27 Sep 2026 20:49:21 +0800 Subject: [PATCH 14/19] perf(glass): prewarm player backdrop and trim tablet compositing --- .../module/hook/PhoneGlassSession.kt | 28 +++++++++++++------ .../kyant/backdrop/DrawBackdropModifier.kt | 12 +++++++- .../amenhancer/glass/NativeLiquidButton.kt | 4 +++ 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt index 506cecd8..2a2c22f1 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt @@ -87,6 +87,7 @@ internal open class PhoneGlassSession( private var menuKey: List = emptyList() private val input = NativeButtonInput() private var slide = 0f + private var returningToMini = false protected val isCollapsed: Boolean get() = slide <= 0.001f private var glassExpansion by androidx.compose.runtime.mutableFloatStateOf(0f) private var miniOffsetInSheet = 0 @@ -272,7 +273,7 @@ internal open class PhoneGlassSession( glass.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS glass.content { HostConfiguration { - NativeLiquidButton(bg, input, glassExpansion, panelBlur = navBlurDp.dp) { sx, sy, x, y -> + NativeLiquidButton(bg, input, glassExpansion, panelBlur = navBlurDp.dp, autoClip = geometry.sideBySide) { sx, sy, x, y -> miniContent?.let { v -> v.scaleX = sx; v.scaleY = sy; v.translationX = x; v.translationY = y } } } @@ -597,11 +598,12 @@ internal open class PhoneGlassSession( val sheetPosition = IntArray(2).also(sheet::getLocationInWindow) miniOffsetInSheet = miniPosition[1] - sheetPosition[1] } - // Per-edge morph (tablet row): each side interpolates from its own - // slot edge to zero, so the pill unfolds from its bottom-right - // anchor into the full sheet while the sheet slides up. - val left = (miniMarginPx[0] * (1f - materialProgress)).roundToInt() - val right = (miniMarginPx[1] * (1f - materialProgress)).roundToInt() + // Keep the tablet's original capsule anchor, but stop the + // horizontal morph at 80% of the sheet width. This reduces + // the fading surface area without changing its timing. + val sideInset = if (geometry.sideBySide) sheet.width * 0.10f * materialProgress else 0f + val left = (miniMarginPx[0] * (1f - materialProgress) + sideInset).roundToInt() + val right = (miniMarginPx[1] * (1f - materialProgress) + sideInset).roundToInt() val top = (miniOffsetInSheet * (1f - materialProgress)).roundToInt() val collapsedHeight = dp(geometry.miniHeightDp) val height = (collapsedHeight + (sheet.height - collapsedHeight) * progress).roundToInt().coerceAtLeast(collapsedHeight) @@ -645,8 +647,11 @@ internal open class PhoneGlassSession( backdropConsumerVisible(navGlass) || backdropConsumerVisible(navScrim) || backdropConsumerVisible(miniGlass)) glassConsumersVisible = consumerVisible - val captureResumed = backdrop?.setCaptureEnabled(consumerVisible) == true - return revivedMiniGlass || captureResumed + // On the way back, capture the source while native player content still + // covers the screen. The mini glass stays GONE until the original 60% boundary. + val prewarmCapture = !consumerVisible && returningToMini && progress in 0.6f..0.85f + val captureResumed = backdrop?.setCaptureEnabled(consumerVisible || prewarmCapture) == true + return revivedMiniGlass || (consumerVisible && captureResumed) } private fun backdropConsumerVisible(view: View?): Boolean { @@ -672,7 +677,12 @@ internal open class PhoneGlassSession( it.isAttachedToWindow && it.width > 0 && it.height > 0 } == true - override fun onSlide(progress: Float) { slide = progress.coerceIn(0f, 1f) } + override fun onSlide(progress: Float) { + val next = progress.coerceIn(0f, 1f) + if (next < slide - 0.001f) returningToMini = true + else if (next > slide + 0.001f) returningToMini = false + slide = next + } override fun redirectedLayerAlpha(view: Any?, alpha: Float): Float? { if (closed || writingLayerAlpha) return null diff --git a/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt b/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt index d91d6adc..008774ab 100644 --- a/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt +++ b/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt @@ -85,6 +85,8 @@ fun Modifier.drawBackdrop( shadow: (() -> Shadow?)? = DefaultShadow, innerShadow: (() -> InnerShadow?)? = null, layerBlock: (GraphicsLayerScope.() -> Unit)? = null, + // Existing callers retain the offscreen clip; the large tablet mini surface can opt into Auto. + clipCompositingStrategy: androidx.compose.ui.graphics.CompositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen, exportedBackdrop: LayerBackdrop? = null, onDrawBehind: (DrawScope.() -> Unit)? = null, onDrawBackdrop: DrawScope.(drawBackdrop: DrawScope.() -> Unit) -> Unit = DefaultOnDrawBackdrop, @@ -136,6 +138,7 @@ fun Modifier.drawBackdrop( shapeProvider = shapeProvider, effects = effects, layerBlock = layerBlock, + clipCompositingStrategy = clipCompositingStrategy, exportedBackdrop = exportedBackdrop, onDrawBehind = onDrawBehind, onDrawBackdrop = onDrawBackdrop, @@ -150,6 +153,7 @@ private class DrawBackdropElement( val shapeProvider: ShapeProvider, val effects: BackdropEffectScope.() -> Unit, val layerBlock: (GraphicsLayerScope.() -> Unit)?, + val clipCompositingStrategy: androidx.compose.ui.graphics.CompositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen, val exportedBackdrop: LayerBackdrop?, val onDrawBehind: (DrawScope.() -> Unit)?, val onDrawBackdrop: DrawScope.(drawBackdrop: DrawScope.() -> Unit) -> Unit, @@ -163,6 +167,7 @@ private class DrawBackdropElement( shapeProvider = shapeProvider, effects = effects, layerBlock = layerBlock, + clipCompositingStrategy = clipCompositingStrategy, exportedBackdrop = exportedBackdrop, onDrawBehind = onDrawBehind, onDrawBackdrop = onDrawBackdrop, @@ -176,6 +181,7 @@ private class DrawBackdropElement( node.shapeProvider = shapeProvider node.effects = effects node.layerBlock = layerBlock + node.clipCompositingStrategy = clipCompositingStrategy if (node.exportedBackdrop != exportedBackdrop) { node.exportedBackdrop?.layerCoordinates = null node.exportedBackdrop = exportedBackdrop @@ -193,6 +199,7 @@ private class DrawBackdropElement( properties["shapeProvider"] = shapeProvider properties["effects"] = effects properties["layerBlock"] = layerBlock + properties["clipCompositingStrategy"] = clipCompositingStrategy properties["exportedBackdrop"] = exportedBackdrop properties["onDrawBehind"] = onDrawBehind properties["onDrawBackdrop"] = onDrawBackdrop @@ -208,6 +215,7 @@ private class DrawBackdropElement( if (shapeProvider != other.shapeProvider) return false if (effects != other.effects) return false if (layerBlock != other.layerBlock) return false + if (clipCompositingStrategy != other.clipCompositingStrategy) return false if (exportedBackdrop != other.exportedBackdrop) return false if (onDrawBehind != other.onDrawBehind) return false if (onDrawBackdrop != other.onDrawBackdrop) return false @@ -222,6 +230,7 @@ private class DrawBackdropElement( result = 31 * result + shapeProvider.hashCode() result = 31 * result + effects.hashCode() result = 31 * result + (layerBlock?.hashCode() ?: 0) + result = 31 * result + clipCompositingStrategy.hashCode() result = 31 * result + (exportedBackdrop?.hashCode() ?: 0) result = 31 * result + (onDrawBehind?.hashCode() ?: 0) result = 31 * result + onDrawBackdrop.hashCode() @@ -236,6 +245,7 @@ private class DrawBackdropNode( var shapeProvider: ShapeProvider, var effects: BackdropEffectScope.() -> Unit, var layerBlock: (GraphicsLayerScope.() -> Unit)?, + var clipCompositingStrategy: androidx.compose.ui.graphics.CompositingStrategy, var exportedBackdrop: LayerBackdrop?, var onDrawBehind: (DrawScope.() -> Unit)?, var onDrawBackdrop: DrawScope.(drawBackdrop: DrawScope.() -> Unit) -> Unit, @@ -254,7 +264,7 @@ private class DrawBackdropNode( private val layoutLayerBlock: GraphicsLayerScope.() -> Unit = { clip = true shape = shapeProvider.shape - compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen + compositingStrategy = clipCompositingStrategy } private var layoutCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy()) diff --git a/glass/src/main/kotlin/dev/amenhancer/glass/NativeLiquidButton.kt b/glass/src/main/kotlin/dev/amenhancer/glass/NativeLiquidButton.kt index a0415f4f..cd765d5e 100644 --- a/glass/src/main/kotlin/dev/amenhancer/glass/NativeLiquidButton.kt +++ b/glass/src/main/kotlin/dev/amenhancer/glass/NativeLiquidButton.kt @@ -41,6 +41,8 @@ fun NativeLiquidButton( input: NativeButtonInput, expansion: Float = 0f, panelBlur: Dp = GlassPolicy.PANEL_BLUR_DP.dp, + /** Let Compose choose the compositing path for the tablet mini surface. */ + autoClip: Boolean = false, transformContent: (Float, Float, Float, Float) -> Unit, ) { val containerColor = if (isSystemInDarkTheme()) Color(0xFF121212).copy(alpha = 0.4f) @@ -60,6 +62,8 @@ fun NativeLiquidButton( Box( Modifier.fillMaxSize().drawBackdrop( backdrop = backdrop, + clipCompositingStrategy = if (autoClip) androidx.compose.ui.graphics.CompositingStrategy.Auto + else androidx.compose.ui.graphics.CompositingStrategy.Offscreen, shape = { if (expansion == 0f) Capsule() else RoundedCornerShape(lerp(GlassPolicy.MINI_HEIGHT_DP / 2f, 24f, expansion).dp) }, effects = { vibrancy() From 09656f7ff3702f30be8912da4610fe127951aade Mon Sep 17 00:00:00 2001 From: ci Date: Sun, 27 Sep 2026 20:49:35 +0800 Subject: [PATCH 15/19] chore(release): bump version to 1.6.2 --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7e8b46a5..cabca72d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,8 +37,8 @@ android { applicationId = "dev.amenhancer.module" minSdk = 26 targetSdk = 37 - versionCode = 111 - versionName = "1.6.1" + versionCode = 112 + versionName = "1.6.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } From cf1fcbd7d52a831db3f4c66af3bab779d2dd6b34 Mon Sep 17 00:00:00 2001 From: ci Date: Sun, 27 Sep 2026 20:57:33 +0800 Subject: [PATCH 16/19] chore(backdrop): pin intentional clip compositing patch --- backdrop/UPSTREAM.md | 10 +++++----- backdrop/local-patches-sha256.json | 3 +++ .../com/kyant/backdrop/DrawBackdropModifier.kt | 9 +++++++++ scripts/verify-glass-reference.py | 17 +++++++++++++---- 4 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 backdrop/local-patches-sha256.json diff --git a/backdrop/UPSTREAM.md b/backdrop/UPSTREAM.md index 3b885769..fac5c8c8 100644 --- a/backdrop/UPSTREAM.md +++ b/backdrop/UPSTREAM.md @@ -5,16 +5,16 @@ AndroidLiquidGlass / Backdrop 2.0.1, by Kyant. - Upstream: https://github.com/Kyant0/AndroidLiquidGlass - Pinned commit: `65ab177e90e5c1d8c62e70cf7755841982da65f6` - License: Apache-2.0; see LICENSE in this directory. -- `src/commonMain` and the original `src/androidMain` files are copied without code edits. `upstream-sha256.json` records SHA256 with CRLF normalized to LF, matching the repository's cross-platform Git checkout rules. +- `src/commonMain` and the original `src/androidMain` files come from upstream. `DrawBackdropModifier.kt` has one AM++ change: configurable clip compositing for the tablet mini-player. `upstream-sha256.json` retains the original SHA256 values; `local-patches-sha256.json` pins the modified file. Both normalize CRLF to LF for cross-platform checkouts. - `src/androidMain/.../backdrops/ViewBackdrop.kt` is an AM++ addition for a hardware-recorded Android View source. - Build configuration targets Android only and uses the application's Java 17 baseline. Desktop, browser, Apple targets and publication tasks are not included. -Run `python scripts/verify-glass-reference.py` from the repository root to verify original rendering sources. +Run `python scripts/verify-glass-reference.py` from the repository root to verify the original rendering sources and the declared local patch. Every file AM++ modified, and every file AM++ added inside the upstream packages, carries a header naming the upstream commit, the Apache-2.0 license -and the change itself, as Apache-2.0 section 4(b) requires. The pristine files -listed in `upstream-sha256.json` are left byte-identical apart from line -endings. +and the change itself, as Apache-2.0 section 4(b) requires. Files without an +entry in `local-patches-sha256.json` remain byte-identical to the upstream +hashes apart from line endings. The seven catalog component/helper files in `glass/src/main/kotlin/com/kyant/backdrop/catalog` have the same source commit and license. AM++ changes: optional host accent and tap-only native reselection in LiquidBottomTabs/DampedDragAnimation, Android-only awaitFrame helper, and native touch entry points on InteractiveHighlight. Original shaders, springs and effect ordering are retained. NativeLiquidButton adapts LiquidButton's material and transform to content whose View/actions remain owned by Apple Music. diff --git a/backdrop/local-patches-sha256.json b/backdrop/local-patches-sha256.json new file mode 100644 index 00000000..b22322ce --- /dev/null +++ b/backdrop/local-patches-sha256.json @@ -0,0 +1,3 @@ +{ + "src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt": "437de087c27a528f11ff2c5d3dba98317464dcf737c9bf79ac00a5d19fc044a3" +} diff --git a/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt b/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt index 008774ab..a1d3bd08 100644 --- a/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt +++ b/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt @@ -1,3 +1,12 @@ +/* + * Derived from AndroidLiquidGlass / Backdrop 2.0.1 + * (https://github.com/Kyant0/AndroidLiquidGlass), commit + * 65ab177e90e5c1d8c62e70cf7755841982da65f6, Apache License 2.0. + * Changed by AM++: allow the tablet mini-player to use automatic clip compositing + * while existing callers keep the original offscreen compositing strategy. + * See backdrop/UPSTREAM.md and THIRD_PARTY_NOTICES.md. + */ + package com.kyant.backdrop import androidx.compose.runtime.getValue diff --git a/scripts/verify-glass-reference.py b/scripts/verify-glass-reference.py index c2f576aa..75505d0b 100644 --- a/scripts/verify-glass-reference.py +++ b/scripts/verify-glass-reference.py @@ -1,15 +1,24 @@ -"""Verify unmodified vendored rendering sources; no external packages or reference checkout needed.""" +"""Verify pinned upstream sources and declared AM++ patches without a reference checkout.""" import hashlib import json from pathlib import Path root = Path(__file__).resolve().parents[1] manifest = json.loads((root / "backdrop/upstream-sha256.json").read_text()) +local_patches = json.loads((root / "backdrop/local-patches-sha256.json").read_text()) +unexpected = local_patches.keys() - manifest.keys() +if unexpected: + raise SystemExit("Unrecognized local Backdrop patches:\n" + "\n".join(sorted(unexpected))) failed = [] -for relative, expected in manifest.items(): +for relative, upstream_hash in manifest.items(): path = root / "backdrop" / relative + expected = local_patches.get(relative, upstream_hash) if not path.is_file() or hashlib.sha256(path.read_bytes().replace(b"\r\n", b"\n")).hexdigest() != expected: failed.append(relative) if failed: - raise SystemExit("Upstream rendering source changed:\n" + "\n".join(failed)) -print(f"PASS: {len(manifest)} original Backdrop files match commit 65ab177e90e5c1d8c62e70cf7755841982da65f6") + raise SystemExit("Backdrop rendering source differs from its pinned hash:\n" + "\n".join(failed)) +print( + f"PASS: {len(manifest) - len(local_patches)} original Backdrop files match " + "commit 65ab177e90e5c1d8c62e70cf7755841982da65f6; " + f"{len(local_patches)} declared AM++ patch(es) match their pinned hashes" +) From c2149620f50c68a491d20cbe146d03adf5702e6e Mon Sep 17 00:00:00 2001 From: ci Date: Sun, 27 Sep 2026 21:32:42 +0800 Subject: [PATCH 17/19] fix(glass): restore native fallback and tablet mini alignment --- .../module/hook/PhoneGlassSession.kt | 63 +++++++++++++------ .../module/hook/TabletDualPaneGlassSession.kt | 13 ++-- ...bletLiquidGlassStructuralRegressionTest.kt | 8 ++- .../amenhancer/glass/NativeLiquidButton.kt | 3 +- 4 files changed, 59 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt index 2a2c22f1..e6296766 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt @@ -89,10 +89,12 @@ internal open class PhoneGlassSession( private var slide = 0f private var returningToMini = false protected val isCollapsed: Boolean get() = slide <= 0.001f + protected val glassMenuReady: Boolean get() = tabs.size > 1 && tabs.any { it.id == selectedId } private var glassExpansion by androidx.compose.runtime.mutableFloatStateOf(0f) private var miniOffsetInSheet = 0 private var navMarginPx = intArrayOf(0, 0) private var miniMarginPx = intArrayOf(0, 0) + private var slottedMiniContent: View? = null private var lastPeek = -1 private val nativePeek = NativePeekHeight() private val attachHandler = android.os.Handler(android.os.Looper.getMainLooper()) @@ -165,10 +167,16 @@ internal open class PhoneGlassSession( */ protected open fun suppressNativeChromeSeams() { hideSeam(find("navigation_tabs_divider")) - // The native tab strip stays alpha-hidden but touchable across its full - // width; invisible taps must never select a native menu item, so seam - // suppression owns its visibility too (restored on close like any seam). - hideSeam(navigation) + navigation?.let { nav -> + if (glassMenuReady) { + // An alpha-hidden native strip still accepts taps across its full width. + hideSeam(nav) + nav.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } else { + // A transient unmappable menu needs the native strip and its controls. + states[nav]?.restoreInteraction(nav) + } + } } protected fun hideSeam(view: View?) { @@ -273,7 +281,8 @@ internal open class PhoneGlassSession( glass.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS glass.content { HostConfiguration { - NativeLiquidButton(bg, input, glassExpansion, panelBlur = navBlurDp.dp, autoClip = geometry.sideBySide) { sx, sy, x, y -> + NativeLiquidButton(bg, input, glassExpansion, panelBlur = navBlurDp.dp, autoClip = geometry.sideBySide, + miniHeightDp = geometry.miniHeightDp) { sx, sy, x, y -> miniContent?.let { v -> v.scaleX = sx; v.scaleY = sy; v.translationX = x; v.translationY = y } } } @@ -283,7 +292,7 @@ internal open class PhoneGlassSession( val surfaceParent = find("player_sheet_container") as? FrameLayout ?: root playerSheet = surfaceParent surfaceParent.addView(glass, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(geometry.miniHeightDp), Gravity.TOP).apply { - val slot = capsuleMarginsPx(0, mini = true) + val slot = capsuleMarginsPx(navFrame?.width ?: 0, mini = true) leftMargin = slot[0]; rightMargin = slot[1] }) if (activated) prepareMini() @@ -365,15 +374,20 @@ internal open class PhoneGlassSession( } } refreshMenu() - if (!activated && backdrop?.ready == true && navGlass?.isLaidOut == true && tabs.size > 1 && tabs.any { it.id == selectedId }) { + if (!activated && backdrop?.ready == true && navGlass?.isLaidOut == true && glassMenuReady) { activate() return false // Layout the new occupied area before exposing either surface. } if (activated) { - val menuReady = tabs.size > 1 && tabs.any { it.id == selectedId } + val menuReady = glassMenuReady val navAlpha = if (menuReady) 1f else 0f navGlass?.alpha = navAlpha navScrim?.alpha = navAlpha + val navGlassRevealed = menuReady && navGlass?.visibility != View.VISIBLE + navGlass?.let { glass -> + val visibility = if (menuReady) View.VISIBLE else View.GONE + if (glass.visibility != visibility) glass.visibility = visibility + } navigation?.alpha = if (menuReady) 0f else 1f updateGeometry() val sourceNeedsLayout = updateUnderlap() @@ -381,7 +395,7 @@ internal open class PhoneGlassSession( // Insets can be reapplied when the native player finishes collapsing. // setLayoutParams only schedules layout: do not expose the old, shorter // content bounds (and window background beneath them) in this frame. - if (sourceNeedsLayout || transitionNeedsLayout || + if (navGlassRevealed || sourceNeedsLayout || transitionNeedsLayout || (glassConsumersVisible && backdrop?.ready == false && canRefreshBackdrop())) return false } else if (backdrop?.ready == true) { // Keep the initial capture for activation, but stop recording while @@ -445,7 +459,7 @@ internal open class PhoneGlassSession( val contentHeight = dp(minOf(GlassPolicy.MINI_HEIGHT_DP, geometry.miniHeightDp)) params.height = contentHeight if (params is ViewGroup.MarginLayoutParams) { - val slot = capsuleMarginsPx(0, mini = true) + val slot = capsuleMarginsPx(navFrame?.width ?: 0, mini = true) params.leftMargin = slot[0]; params.rightMargin = slot[1] val topOffset = (dp(geometry.miniHeightDp) - contentHeight) / 2 if (topOffset != 0) params.topMargin = topOffset @@ -474,20 +488,27 @@ internal open class PhoneGlassSession( // resync every surface whenever a resolved slot edge changes. val navSlot = capsuleMarginsPx(frame.width, mini = false) val miniSlot = capsuleMarginsPx(frame.width, mini = true) - if (!navSlot.contentEquals(navMarginPx) || !miniSlot.contentEquals(miniMarginPx)) { + fun applySlot(view: View?, slot: IntArray) { + val surface = view ?: return + val params = surface.layoutParams as? ViewGroup.MarginLayoutParams ?: return + if (params.leftMargin != slot[0] || params.rightMargin != slot[1]) { + params.leftMargin = slot[0]; params.rightMargin = slot[1] + surface.layoutParams = params + } + } + val slotsChanged = !navSlot.contentEquals(navMarginPx) || !miniSlot.contentEquals(miniMarginPx) + if (slotsChanged) { navMarginPx = navSlot miniMarginPx = miniSlot - fun applySlot(view: View?, slot: IntArray) { - val surface = view ?: return - val params = surface.layoutParams as? ViewGroup.MarginLayoutParams ?: return - if (params.leftMargin != slot[0] || params.rightMargin != slot[1]) { - params.leftMargin = slot[0]; params.rightMargin = slot[1] - surface.layoutParams = params - } - } applySlot(navGlass, navSlot) applySlot(miniGlass, miniSlot) + } + // A new mini_player can arrive without a frame-width change. Its native + // content must still occupy the cached slot, without resetting the + // animated miniGlass margins on every transition frame. + if (slotsChanged || slottedMiniContent !== miniContent) { applySlot(miniContent, miniSlot) + slottedMiniContent = miniContent } } val peek = peekHeight() @@ -797,6 +818,10 @@ internal open class PhoneGlassSession( private val clipPadding = (view as? ViewGroup)?.clipToPadding val outlineProvider = view.outlineProvider private val transform = floatArrayOf(view.scaleX, view.scaleY, view.translationX, view.translationY) + fun restoreInteraction(view: View) { + if (view.visibility != visibility) view.visibility = visibility + if (view.importantForAccessibility != accessibility) view.importantForAccessibility = accessibility + } fun restoreScroll(view: View) { view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, padding[3]) if (view is ViewGroup) clipPadding?.let { view.clipToPadding = it } diff --git a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt index 2b0db16d..984a1955 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt @@ -119,7 +119,7 @@ internal class TabletDualPaneGlassSession( private fun passesThrough(event: MotionEvent): Boolean { if (event.actionMasked == MotionEvent.ACTION_DOWN) { - val accepted = !activated || !isCollapsed || capsuleHit(event)?.any != false + val accepted = !activated || !isCollapsed || !glassMenuReady || capsuleHit(event)?.any != false return touchGate.start(event.downTime, hitCapsule = accepted) } // Keep the initial target for the whole gesture, including a move into a capsule. @@ -139,8 +139,9 @@ internal class TabletDualPaneGlassSession( private fun capsuleHit(event: MotionEvent): CapsuleHit? { val frame = navFrame ?: return null - val navigation = navGlass?.takeIf { it.isShown && it.width > 0 && it.height > 0 } ?: return null - val mini = if (miniVisible) miniGlass?.takeIf { it.isShown && it.width > 0 && it.height > 0 } ?: return null else null + val navigation = navGlass?.takeIf { it.isShown && it.width > 0 && it.height > 0 } + val mini = if (miniVisible) miniGlass?.takeIf { it.isShown && it.width > 0 && it.height > 0 } else null + if (navigation == null && mini == null) return null val origin = IntArray(2).also(frame::getLocationOnScreen) val x = event.rawX - origin[0] val y = event.rawY - origin[1] @@ -150,10 +151,12 @@ internal class TabletDualPaneGlassSession( val top = (location[1] - origin[1]).toFloat() return GlassCapsuleBounds(left, top, left + view.width, top + view.height) } + val navBounds = navigation?.let(::bounds) val miniBounds = mini?.let(::bounds) + val miniHit = miniBounds?.let { TabletGlassLayoutPolicy.contains(x, y, it) } == true return CapsuleHit( - any = TabletGlassLayoutPolicy.containsEither(x, y, bounds(navigation), miniBounds), - mini = miniBounds?.let { TabletGlassLayoutPolicy.contains(x, y, it) } == true, + any = navBounds?.let { TabletGlassLayoutPolicy.containsEither(x, y, it, miniBounds) } ?: miniHit, + mini = miniHit, ) } diff --git a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt index 804a6953..8c58589b 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt @@ -87,9 +87,11 @@ class TabletLiquidGlassStructuralRegressionTest { val session = source("dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt") val base = source("dev/amenhancer/module/hook/PhoneGlassSession.kt") val runtime = source("dev/amenhancer/module/hook/PhoneGlassRuntime.kt") - // The invisible native tabs stay gone, while the full-width roots and - // player behavior pass blank-area gestures to the underlying page. - assertTrue(base.contains("hideSeam(navigation)")) + // Hide native tabs while glass owns the menu, but restore them if the + // visible menu cannot be mapped. Blank-area gestures pass through. + assertTrue(base.contains("hideSeam(nav)")) + assertTrue(base.contains("restoreInteraction(nav)")) + assertTrue(session.contains("!glassMenuReady")) assertTrue(session.contains("TabletGlassLayoutPolicy.containsEither")) assertTrue(runtime.contains("it.shouldPassThroughTouch(root, event)")) assertTrue(runtime.contains("it.shouldBypassPlayerIntercept(event)")) diff --git a/glass/src/main/kotlin/dev/amenhancer/glass/NativeLiquidButton.kt b/glass/src/main/kotlin/dev/amenhancer/glass/NativeLiquidButton.kt index cd765d5e..4419031d 100644 --- a/glass/src/main/kotlin/dev/amenhancer/glass/NativeLiquidButton.kt +++ b/glass/src/main/kotlin/dev/amenhancer/glass/NativeLiquidButton.kt @@ -43,6 +43,7 @@ fun NativeLiquidButton( panelBlur: Dp = GlassPolicy.PANEL_BLUR_DP.dp, /** Let Compose choose the compositing path for the tablet mini surface. */ autoClip: Boolean = false, + miniHeightDp: Int = GlassPolicy.MINI_HEIGHT_DP, transformContent: (Float, Float, Float, Float) -> Unit, ) { val containerColor = if (isSystemInDarkTheme()) Color(0xFF121212).copy(alpha = 0.4f) @@ -64,7 +65,7 @@ fun NativeLiquidButton( backdrop = backdrop, clipCompositingStrategy = if (autoClip) androidx.compose.ui.graphics.CompositingStrategy.Auto else androidx.compose.ui.graphics.CompositingStrategy.Offscreen, - shape = { if (expansion == 0f) Capsule() else RoundedCornerShape(lerp(GlassPolicy.MINI_HEIGHT_DP / 2f, 24f, expansion).dp) }, + shape = { if (expansion == 0f) Capsule() else RoundedCornerShape(lerp(miniHeightDp / 2f, 24f, expansion).dp) }, effects = { vibrancy() blur(panelBlur.toPx()) From d32f4e97073f69d5345dce9adeda3be5e2c3804f Mon Sep 17 00:00:00 2001 From: ci Date: Sun, 27 Sep 2026 21:55:56 +0800 Subject: [PATCH 18/19] fix(glass): resolve artwork slide callback by host build --- .../amenhancer/module/hook/PhoneGlassRuntime.kt | 11 +++++++---- .../dev/amenhancer/module/hook/TargetSymbols.kt | 7 +++++++ .../module/hook/AppleMusic653ProfileTest.kt | 17 +++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt index c938dd95..40e5897f 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt @@ -44,7 +44,7 @@ internal object PhoneGlassRuntime { view.post { if (activity in failed || activity.isDestroyed) return@post try { - installHooks(activity.classLoader) + installHooks(activity.classLoader, build) sessions[activity]?.takeUnless { it.ownsCurrentHierarchy() }?.let { it.close(); sessions.remove(activity) } val desired = createSession(activity, config) { error -> fail(activity, config, error) } if (desired == null) { sessions.remove(activity)?.close(); return@post } @@ -81,7 +81,7 @@ internal object PhoneGlassRuntime { "玻璃接入失败,已恢复原生界面:${error.javaClass.simpleName}: ${error.message}", targetBuild(activity).displayName)) } - private fun installHooks(loader: ClassLoader) { + private fun installHooks(loader: ClassLoader, build: TargetBuild) { if (hooksInstalled) return check(!hooksAttempted) { "Glass hook installation previously failed; restart the host to retry" } hooksAttempted = true @@ -144,7 +144,10 @@ internal object PhoneGlassRuntime { // source alignment after that write, leaving its scale and the glass // transition untouched. The callback is optional on other host builds. runCatching { - val callback = loader.loadClass("com.apple.android.music.player.fragment.v0\$k") + val callbackName = checkNotNull(AppleMusicSymbols.playerArtworkSlideCallbackClassName(build)) { + "No artwork slide callback profile for ${build.displayName}" + } + val callback = loader.loadClass(callbackName) val artworkField = callback.getDeclaredField("a").apply { isAccessible = true } val slideMethod = callback.getDeclaredMethod("c", Float::class.javaPrimitiveType!!) ModernXposedRuntime.hookMethod(slideMethod, object : ModernMethodHook() { @@ -156,7 +159,7 @@ internal object PhoneGlassRuntime { } } }) - } + }.onFailure { ModernXposedRuntime.log("liquid glass artwork alignment hook unavailable for ${build.displayName}", it) } // Apple's scrolling behavior reserves bottom padding on the content host. // Redirect it before setPadding rather than fighting it with another layout every frame. ModernXposedRuntime.hookMethod(View::class.java.getDeclaredMethod("setPadding", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType), object : ModernMethodHook() { diff --git a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt index a31bb80d..9dc654ff 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt @@ -238,6 +238,7 @@ internal data class AppleMusicProfile( internal enum class TargetSymbolId { PLAYER_CONTROLLER, + PLAYER_ARTWORK_SLIDE_CALLBACK, PLAYER_ACTIVITY, PLAYER_ACTIVITY_CREATE_STACKED_NAVIGATION_HOLDER, PLAYER_ACTIVITY_ROOT, @@ -364,6 +365,7 @@ private object AppleMusicProfiles { id = "apple-music-6.5.3-1599", exactClasses = mapOf( TargetSymbolId.PLAYER_CONTROLLER to "com.apple.android.music.player.fragment.v0", + TargetSymbolId.PLAYER_ARTWORK_SLIDE_CALLBACK to "com.apple.android.music.player.fragment.v0\$k", TargetSymbolId.PLAYER_ACTIVITY to "com.apple.android.music.common.activity.PlayerActivity", TargetSymbolId.EDITORIAL_VIDEO_OWNER to "com.apple.android.music.player.f1", TargetSymbolId.LYRICS_FRAGMENT to "com.apple.android.music.player.fragment.PlayerLyricsViewFragment", @@ -409,6 +411,7 @@ private object AppleMusicProfiles { id = "apple-music-6.5.2-1586", exactClasses = mapOf( TargetSymbolId.PLAYER_CONTROLLER to "com.apple.android.music.player.fragment.t0", + TargetSymbolId.PLAYER_ARTWORK_SLIDE_CALLBACK to "com.apple.android.music.player.fragment.t0\$k", TargetSymbolId.PLAYER_ACTIVITY to "com.apple.android.music.common.activity.PlayerActivity", TargetSymbolId.EDITORIAL_VIDEO_OWNER to "com.apple.android.music.player.f1", TargetSymbolId.LYRICS_FRAGMENT to "com.apple.android.music.player.fragment.PlayerLyricsViewFragment", @@ -464,6 +467,10 @@ private object AppleMusicProfiles { } internal object AppleMusicSymbols { + /** The verified BottomSheet callback that owns cover alignment on each glass-supported build. */ + fun playerArtworkSlideCallbackClassName(build: TargetBuild): String? = + AppleMusicProfiles.match(build)?.exactClasses?.get(TargetSymbolId.PLAYER_ARTWORK_SLIDE_CALLBACK) + /** * Apple Music 6.5.2/1586's karaoke transition entry point * (`com.apple.android.music.player.z.a0(z$a, int, int, int, boolean)`). diff --git a/app/src/test/java/dev/amenhancer/module/hook/AppleMusic653ProfileTest.kt b/app/src/test/java/dev/amenhancer/module/hook/AppleMusic653ProfileTest.kt index 4d113edb..cd5e68db 100644 --- a/app/src/test/java/dev/amenhancer/module/hook/AppleMusic653ProfileTest.kt +++ b/app/src/test/java/dev/amenhancer/module/hook/AppleMusic653ProfileTest.kt @@ -22,6 +22,23 @@ class AppleMusic653ProfileTest { private val build653 = TargetBuild(ModuleConstants.TARGET_PACKAGE, "6.5.3", 1599L) private val version653 = AppleMusicVersion("6.5.3", 1599L) + @Test + fun `glass artwork slide callback follows the selected build profile`() { + assertEquals( + "com.apple.android.music.player.fragment.t0\$k", + AppleMusicSymbols.playerArtworkSlideCallbackClassName( + TargetBuild(ModuleConstants.TARGET_PACKAGE, "6.5.2", 1586L), + ), + ) + assertEquals( + "com.apple.android.music.player.fragment.v0\$k", + AppleMusicSymbols.playerArtworkSlideCallbackClassName(build653), + ) + assertNull(AppleMusicSymbols.playerArtworkSlideCallbackClassName( + TargetBuild(ModuleConstants.TARGET_PACKAGE, "6.5.3", 1586L), + )) + } + @Test fun `bootstrap accepts only the exact 6_5_3 tuple`() { val bootstrap = EmbeddedBootstrap() From 049d22eaa32e789025d6af2affa2e2ec6e2a9d5f Mon Sep 17 00:00:00 2001 From: ci Date: Sun, 27 Sep 2026 22:19:01 +0800 Subject: [PATCH 19/19] fix(glass): restore stacked tablet peek baseline --- .../main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt | 4 ++-- .../dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt index e6296766..d49b0ab1 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt @@ -202,8 +202,8 @@ internal open class PhoneGlassSession( protected open fun resolveBottomNavigationRoot(): View? = find("bottom_navigation_root_stacked") - // The stacked native holder reserves miniplayer_height even when mini is hidden, - // on top of the tabs height and the bottom inset. + // The stacked native holder (also installed by the tablet dual-pane adaptation) + // reserves miniplayer_height even when mini is hidden, plus tabs and bottom inset. protected open fun nativePeekBaseline(): Int = bottomInset + dimen("navigation_tabs_height") + dimen("miniplayer_height") diff --git a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt index 984a1955..938bd801 100644 --- a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt +++ b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt @@ -169,9 +169,6 @@ internal class TabletDualPaneGlassSession( override fun resolveBottomNavigationRoot(): View? = find("bottom_navigation_root_flat") ?: find("bottom_navigation_root_stacked") - // The flat holder reserves miniplayer_height only (no navigation_tabs_height). - override fun nativePeekBaseline(): Int = bottomInset + dimen("miniplayer_height") - /** The elevated tabs frame sits above player_container. Keep its full-width fade * below both capsules so it cannot wash over the mini player's glass. */ override fun attachNavigationScrim(frame: FrameLayout, scrim: GlassHostView) {