diff --git a/README.md b/README.md
index dca4911a..a2ab35f5 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
AM++
- Apple Music 的 Android 增强模块:平板双栏、歌词模糊与字体、自定义歌词、歌曲名修正、手机液态玻璃底栏。
+ Apple Music 的 Android 增强模块:平板双栏、歌词模糊与字体、自定义歌词、歌曲名修正、液态玻璃底栏。
@@ -57,10 +57,10 @@ 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。生效范围是手机布局与开启“平板双栏播放器”的平板横屏(平板竖屏或双栏关闭时保持原生界面);平板形态下底栏与迷你播放器并排在同一条底部胶囊行内(底栏在左、迷你在右、同高居中),整行约 2/3 屏宽居中、左右留白等长,展开播放器时右下小胶囊四边插值摊开为全屏。底栏使用 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/hook/AppleMusicDualPaneTarget.kt b/app/src/main/java/dev/amenhancer/module/hook/AppleMusicDualPaneTarget.kt
index dac41863..9c8c7ad4 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,24 @@ 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)) {
+ // 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/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..0f13f209 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
+ protected 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)
@@ -82,6 +84,8 @@ internal class PhoneGlassSession(
private var slide = 0f
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 lastPeek = -1
private val nativePeek = NativePeekHeight()
private val attachHandler = android.os.Handler(android.os.Looper.getMainLooper())
@@ -101,20 +105,44 @@ 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
+
+ /**
+ * 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)
+ }
+
+ /** 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()
- 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)
@@ -122,14 +150,35 @@ 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) }
+ /**
+ * 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"))
+ // 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?) {
+ 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) {
@@ -140,9 +189,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 +219,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
@@ -182,8 +251,9 @@ internal 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) } }
+ 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 = dp(16); rightMargin = dp(16)
+ leftMargin = navSlot[0]; rightMargin = navSlot[1]
})
observer = activity.window.decorView.viewTreeObserver.also { it.addOnPreDrawListener(this); it.addOnGlobalLayoutListener(layoutListener) }
}
@@ -208,14 +278,15 @@ internal 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 = dp(16); rightMargin = dp(16)
+ 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()
}
}
- 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 +355,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
}
@@ -333,10 +404,9 @@ internal 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) }
- // 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 +414,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))
}
@@ -352,13 +424,19 @@ internal 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 = dp(16); params.rightMargin = dp(16) }
+ 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)
@@ -371,6 +449,7 @@ internal class PhoneGlassSession(
}
}
}
+ miniContent?.let(::armMiniTap)
listOf("player_root", "player_top_shadow", "background_layers", "motion_switcher", "player_fragments_host").mapNotNull(::find).forEach(::save)
}
@@ -379,6 +458,25 @@ internal 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 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()
if (lastPeek != peek) {
@@ -387,9 +485,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 +541,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)
@@ -462,12 +560,14 @@ internal 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.
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)
@@ -482,14 +582,18 @@ internal class PhoneGlassSession(
val sheetPosition = IntArray(2).also(sheet::getLocationInWindow)
miniOffsetInSheet = miniPosition[1] - sheetPosition[1]
}
- val margin = (dp(16) * (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
}
}
@@ -512,9 +616,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 +633,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 +648,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 +662,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 +690,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..adc442c3
--- /dev/null
+++ b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt
@@ -0,0 +1,98 @@
+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
+
+ // 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 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 =
+ 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)
+ }
+
+ // 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.
+ 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/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt b/app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt
index e2bf9e04..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,23 +2174,28 @@ internal class EmbeddedSettingsHost private constructor(
EmbeddedSettingsPalette.primary,
),
) { onSettingsChanged(settings.copy(dualPaneEnabled = 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,
- "平板底栏补偿",
- "如果底栏显示异常开启该选项",
- settings.navigationCompensationEnabled,
- iconTint = EmbeddedSettingsPalette.primary,
- iconDrawable = EmbeddedGlyphDrawable(
- EmbeddedGlyphKind.BottomBar,
- EmbeddedSettingsPalette.primary,
- ),
- ) { onSettingsChanged(settings.copy(navigationCompensationEnabled = it)) })
- addView(embeddedDivider(activity))
- addView(embeddedSettingRow(
- activity,
- "手机液态玻璃底栏",
- "为手机底栏和迷你播放器启用液态玻璃效果,需重开应用",
+ "液态玻璃底栏",
+ "为手机与开启平板双栏播放器的平板横屏的底栏和迷你播放器启用液态玻璃效果,需重开应用",
settings.phoneLiquidGlassEnabled,
iconTint = EmbeddedSettingsPalette.accent,
iconDrawable = EmbeddedGlyphDrawable(
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()"))
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..16f09075
--- /dev/null
+++ b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt
@@ -0,0 +1,136 @@
+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)
+ // 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
+ 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")
+ // 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
+ 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")
+ // 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")
+ 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")
+ 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"))
+ }
+}
diff --git a/glass/src/main/kotlin/dev/amenhancer/glass/GlassPolicy.kt b/glass/src/main/kotlin/dev/amenhancer/glass/GlassPolicy.kt
index 69c9a1be..da9239b7 100644
--- a/glass/src/main/kotlin/dev/amenhancer/glass/GlassPolicy.kt
+++ b/glass/src/main/kotlin/dev/amenhancer/glass/GlassPolicy.kt
@@ -1,5 +1,28 @@
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,
+ /** Side-by-side forms put the mini capsule beside the nav capsule in one row. */
+ val sideBySide: Boolean = false,
+) {
+ companion object {
+ val Phone = GlassGeometry()
+ /** Tablet dual-pane row: both capsules share one 56dp row (2026-09-22 sketch). */
+ val Tablet = GlassGeometry(miniHeightDp = 56, sideBySide = true)
+ }
+}
+
/** 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 +50,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 +69,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.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
new file mode 100644
index 00000000..d48a2096
--- /dev/null
+++ b/glass/src/test/kotlin/dev/amenhancer/glass/GlassGeometryTest.kt
@@ -0,0 +1,74 @@
+package dev.amenhancer.glass
+
+import org.junit.Assert.*
+import org.junit.Test
+
+class GlassGeometryTest {
+ @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)
+ 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 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))
+ 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))
+ }
+}