From ee97a29056cceb8988645a0f338e57b9b060544a Mon Sep 17 00:00:00 2001 From: ci Date: Wed, 23 Sep 2026 11:41:40 +0800 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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")