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/build.gradle.kts b/app/build.gradle.kts
index 7e8b46a5..cabca72d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -37,8 +37,8 @@ android {
applicationId = "dev.amenhancer.module"
minSdk = 26
targetSdk = 37
- versionCode = 111
- versionName = "1.6.1"
+ versionCode = 112
+ versionName = "1.6.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
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..c1f716f9
--- /dev/null
+++ b/app/src/main/java/dev/amenhancer/module/hook/GlassSession.kt
@@ -0,0 +1,31 @@
+package dev.amenhancer.module.hook
+
+import android.view.MotionEvent
+import android.view.View
+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 shouldPassThroughTouch(view: View, event: MotionEvent): Boolean
+ fun shouldBypassPlayerIntercept(event: MotionEvent): Boolean
+ /** Returns null for normal dispatch, or the native mini player's handled result. */
+ fun dispatchCollapsedMiniTouch(view: View, event: MotionEvent): Boolean?
+ fun observeTouch(event: MotionEvent)
+ fun foreground(active: Boolean)
+ override fun close()
+}
diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt
index 21667a16..40e5897f 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,20 +37,42 @@ 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 {
if (activity in failed || activity.isDestroyed) return@post
try {
- installHooks(activity.classLoader)
+ installHooks(activity.classLoader, build)
sessions[activity]?.takeUnless { it.ownsCurrentHierarchy() }?.let { it.close(); sessions.remove(activity) }
- val 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()
@@ -58,25 +81,56 @@ internal object PhoneGlassRuntime {
"玻璃接入失败,已恢复原生界面:${error.javaClass.simpleName}: ${error.message}", targetBuild(activity).displayName))
}
- private fun installHooks(loader: ClassLoader) {
+ private fun installHooks(loader: ClassLoader, build: TargetBuild) {
if (hooksInstalled) return
check(!hooksAttempted) { "Glass hook installation previously failed; restart the host to retry" }
hooksAttempted = true
- 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())
- }
- })
+ val behavior = loader.loadClass("com.apple.android.music.player.PlayerBottomSheetBehavior")
+ // The host obfuscates this override's name; the Coordinator/View/MotionEvent
+ // signature is the verified interception seam on the supported builds.
+ val intercept = behavior.declaredMethods.single { candidate ->
+ val parameters = candidate.parameterTypes
+ candidate.returnType == Boolean::class.javaPrimitiveType &&
+ parameters.size == 3 &&
+ parameters[0].name == "androidx.coordinatorlayout.widget.CoordinatorLayout" &&
+ View::class.java.isAssignableFrom(parameters[1]) &&
+ parameters[2] == MotionEvent::class.java
+ }.apply { isAccessible = true }
+ val peek = method(behavior, "F", Int::class.javaPrimitiveType!!, Boolean::class.javaPrimitiveType!!)
+ // The stacked and flat holders each drive their own slide contract; the outer
+ // activity reflection resolves both holder shapes.
+ for (holderName in listOf("StackedBottomNavigationHolder", "FlatBottomNavigationHolder")) {
+ 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
- sessions.values.firstOrNull { it.miniRoot === root }?.observeTouch(param.args[0] as MotionEvent)
+ val event = param.args[0] as MotionEvent
+ if (sessions.values.any { it.shouldPassThroughTouch(root, event) }) {
+ param.result = false
+ return
+ }
+ sessions.values.firstNotNullOfOrNull { it.dispatchCollapsedMiniTouch(root, event) }?.let {
+ param.result = it
+ return
+ }
+ sessions.values.firstOrNull { it.miniRoot === root }?.observeTouch(event)
+ }
+ })
+ ModernXposedRuntime.hookMethod(intercept, object : ModernMethodHook() {
+ override fun beforeHookedMethod(param: MethodHookParam) {
+ val event = param.args.getOrNull(2) as? MotionEvent ?: return
+ if (sessions.values.any { it.playerBehavior === param.thisObject && it.shouldBypassPlayerIntercept(event) }) {
+ param.result = false
+ }
}
})
- val behavior = loader.loadClass("com.apple.android.music.player.PlayerBottomSheetBehavior")
- val peek = method(behavior, "F", Int::class.javaPrimitiveType!!, Boolean::class.javaPrimitiveType!!)
ModernXposedRuntime.hookMethod(peek, object : ModernMethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
sessions.values.firstOrNull { it.playerBehavior === param.thisObject }?.let {
@@ -85,6 +139,27 @@ internal object PhoneGlassRuntime {
}
}
})
+ // Apple's artwork callback computes the cover transform from the mini
+ // thumbnail and then writes it each slide frame. Apply the tablet-only
+ // source alignment after that write, leaving its scale and the glass
+ // transition untouched. The callback is optional on other host builds.
+ runCatching {
+ val callbackName = checkNotNull(AppleMusicSymbols.playerArtworkSlideCallbackClassName(build)) {
+ "No artwork slide callback profile for ${build.displayName}"
+ }
+ val callback = loader.loadClass(callbackName)
+ val artworkField = callback.getDeclaredField("a").apply { isAccessible = true }
+ val slideMethod = callback.getDeclaredMethod("c", Float::class.javaPrimitiveType!!)
+ ModernXposedRuntime.hookMethod(slideMethod, object : ModernMethodHook() {
+ override fun afterHookedMethod(param: MethodHookParam) {
+ val artwork = artworkField.get(param.thisObject) as? View ?: return
+ val progress = (param.args[0] as? Number)?.toFloat() ?: return
+ sessions.values.forEach { session ->
+ (session as? TabletDualPaneGlassSession)?.alignNativeArtworkStart(artwork, progress)
+ }
+ }
+ })
+ }.onFailure { ModernXposedRuntime.log("liquid glass artwork alignment hook unavailable for ${build.displayName}", it) }
// Apple's scrolling behavior reserves bottom padding on the content host.
// Redirect it before setPadding rather than fighting it with another layout every frame.
ModernXposedRuntime.hookMethod(View::class.java.getDeclaredMethod("setPadding", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType), object : ModernMethodHook() {
diff --git a/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/PhoneGlassSession.kt
index 5926fc39..d49b0ab1 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,36 @@ 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 val visibleGlassRect = android.graphics.Rect()
+ private val visibleGlassLocation = IntArray(2)
+ private val windowLocation = IntArray(2)
+ private var glassConsumersVisible = false
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
+ protected var navGlass: GlassHostView? = null
+ protected var navScrim: GlassHostView? = null
+ protected var miniGlass: GlassHostView? = 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
+ protected var playerSheet: View? = null
+ final override var activated = false
private set
private var tabs by mutableStateOf(emptyList())
private var selectedId by mutableIntStateOf(View.NO_ID)
@@ -80,8 +87,14 @@ internal class PhoneGlassSession(
private var menuKey: List = emptyList()
private val input = NativeButtonInput()
private var slide = 0f
+ private var returningToMini = false
+ protected val isCollapsed: Boolean get() = slide <= 0.001f
+ protected val glassMenuReady: Boolean get() = tabs.size > 1 && tabs.any { it.id == selectedId }
private var glassExpansion by androidx.compose.runtime.mutableFloatStateOf(0f)
private var miniOffsetInSheet = 0
+ private var navMarginPx = intArrayOf(0, 0)
+ private var miniMarginPx = intArrayOf(0, 0)
+ private var slottedMiniContent: View? = null
private var lastPeek = -1
private val nativePeek = NativePeekHeight()
private val attachHandler = android.os.Handler(android.os.Looper.getMainLooper())
@@ -101,20 +114,35 @@ 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
+
+ protected open fun playerFragmentsAlphaFactor(progress: Float, materialProgress: Float): Float = materialProgress
+
+ /**
+ * Horizontal slot of a floating capsule as [left, right] margins. The phone
+ * keeps the tuned symmetric margins; the tablet row carves asymmetric slots
+ * (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.
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,41 @@ 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"))
+ navigation?.let { nav ->
+ if (glassMenuReady) {
+ // An alpha-hidden native strip still accepts taps across its full width.
+ hideSeam(nav)
+ nav.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
+ } else {
+ // A transient unmappable menu needs the native strip and its controls.
+ states[nav]?.restoreInteraction(nav)
+ }
+ }
+ }
+
+ protected fun hideSeam(view: View?) {
+ 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 +195,34 @@ 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 (also installed by the tablet dual-pane adaptation)
+ // reserves miniplayer_height even when mini is hidden, plus tabs and bottom inset.
+ protected open fun nativePeekBaseline(): Int =
+ bottomInset + dimen("navigation_tabs_height") + dimen("miniplayer_height")
+
+ /** Capsule exit driver; the phone host translates the frame from its own holder. */
+ protected open fun driveNavFrameExit(progress: Float) = Unit
+
+ /** Phone keeps its bottom fade inside the native tabs frame. */
+ protected open fun attachNavigationScrim(frame: FrameLayout, scrim: GlassHostView) {
+ frame.addView(scrim, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
+ }
+
+ /** Chrome ownership hand-off; only the dual-pane session arbitrates ownership. */
+ protected open fun onGlassOwnership(root: View?) = Unit
+
+ 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 +230,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
@@ -178,12 +258,13 @@ internal class PhoneGlassSession(
scrim.alpha = 0f
scrim.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
scrim.content { HostConfiguration { BottomScrim(bg) } }
- frame.addView(scrim, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
+ attachNavigationScrim(frame, scrim)
val glass = GlassHostView(moduleContext()).also { navGlass = it }
glass.alpha = 0f
glass.content { HostConfiguration { GlassNavigation(tabs, selectedId, accent, foreground, bg, ::selectTab, panelBlur = navBlurDp.dp) } }
+ 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) }
}
@@ -200,7 +281,8 @@ internal class PhoneGlassSession(
glass.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
glass.content {
HostConfiguration {
- NativeLiquidButton(bg, input, glassExpansion, panelBlur = navBlurDp.dp) { sx, sy, x, y ->
+ NativeLiquidButton(bg, input, glassExpansion, panelBlur = navBlurDp.dp, autoClip = geometry.sideBySide,
+ miniHeightDp = geometry.miniHeightDp) { sx, sy, x, y ->
miniContent?.let { v -> v.scaleX = sx; v.scaleY = sy; v.translationX = x; v.translationY = y }
}
}
@@ -208,14 +290,16 @@ 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)
+ playerSheet = surfaceParent
+ surfaceParent.addView(glass, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(geometry.miniHeightDp), Gravity.TOP).apply {
+ val slot = capsuleMarginsPx(navFrame?.width ?: 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,29 +368,42 @@ 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
}
}
refreshMenu()
- if (!activated && backdrop?.ready == true && navGlass?.isLaidOut == true && tabs.size > 1 && tabs.any { it.id == selectedId }) {
+ if (!activated && backdrop?.ready == true && navGlass?.isLaidOut == true && glassMenuReady) {
activate()
return false // Layout the new occupied area before exposing either surface.
}
if (activated) {
- val menuReady = tabs.size > 1 && tabs.any { it.id == selectedId }
+ val menuReady = glassMenuReady
val navAlpha = if (menuReady) 1f else 0f
navGlass?.alpha = navAlpha
navScrim?.alpha = navAlpha
+ val navGlassRevealed = menuReady && navGlass?.visibility != View.VISIBLE
+ navGlass?.let { glass ->
+ val visibility = if (menuReady) View.VISIBLE else View.GONE
+ if (glass.visibility != visibility) glass.visibility = visibility
+ }
navigation?.alpha = if (menuReady) 0f else 1f
updateGeometry()
val sourceNeedsLayout = updateUnderlap()
- updateTransition()
+ val transitionNeedsLayout = updateTransition()
// Insets can be reapplied when the native player finishes collapsing.
// setLayoutParams only schedules layout: do not expose the old, shorter
// content bounds (and window background beneath them) in this frame.
- if (sourceNeedsLayout) return false
+ if (navGlassRevealed || sourceNeedsLayout || transitionNeedsLayout ||
+ (glassConsumersVisible && backdrop?.ready == false && canRefreshBackdrop())) return false
+ } else if (backdrop?.ready == true) {
+ // Keep the initial capture for activation, but stop recording while
+ // the menu has not produced a visible glass surface yet.
+ backdrop?.setCaptureEnabled(
+ backdropConsumerVisible(navGlass) || backdropConsumerVisible(navScrim) ||
+ backdropConsumerVisible(miniGlass),
+ )
}
} catch (error: Throwable) { scheduleFailure(error) }
return true
@@ -319,6 +416,7 @@ internal class PhoneGlassSession(
// Read the laid-out native state before changing peek height or hiding any layer.
val sheet = find("player_sheet_container") ?: return
if (!sheet.isLaidOut) return
+ playerSheet = sheet
val behavior = checkNotNull(playerBehavior)
val base = activity.classLoader.loadClass("com.google.android.material.bottomsheet.BottomSheetBehavior")
val state = base.getDeclaredField("G").apply { isAccessible = true }.getInt(behavior)
@@ -333,10 +431,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 +441,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 +451,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(navFrame?.width ?: 0, mini = true)
+ params.leftMargin = slot[0]; params.rightMargin = slot[1]
+ val topOffset = (dp(geometry.miniHeightDp) - contentHeight) / 2
+ if (topOffset != 0) params.topMargin = topOffset
+ }
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)
@@ -379,6 +484,32 @@ 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)
+ fun applySlot(view: View?, slot: IntArray) {
+ val surface = view ?: return
+ val params = surface.layoutParams as? ViewGroup.MarginLayoutParams ?: return
+ if (params.leftMargin != slot[0] || params.rightMargin != slot[1]) {
+ params.leftMargin = slot[0]; params.rightMargin = slot[1]
+ surface.layoutParams = params
+ }
+ }
+ val slotsChanged = !navSlot.contentEquals(navMarginPx) || !miniSlot.contentEquals(miniMarginPx)
+ if (slotsChanged) {
+ navMarginPx = navSlot
+ miniMarginPx = miniSlot
+ applySlot(navGlass, navSlot)
+ applySlot(miniGlass, miniSlot)
+ }
+ // A new mini_player can arrive without a frame-width change. Its native
+ // content must still occupy the cached slot, without resetting the
+ // animated miniGlass margins on every transition frame.
+ if (slotsChanged || slottedMiniContent !== miniContent) {
+ applySlot(miniContent, miniSlot)
+ slottedMiniContent = miniContent
+ }
}
val peek = peekHeight()
if (lastPeek != peek) {
@@ -387,9 +518,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 +574,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)
@@ -461,48 +592,68 @@ internal class PhoneGlassSession(
return sourceNeedsLayout
}
- private fun updateTransition() {
+ private fun updateTransition(): Boolean {
+ suppressNativeChromeSeams()
navFrame?.background = null
// Keep Z ordering (also used for touch dispatch); remove only the old
// 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)
}
val materialProgress = blend(0f, 0.35f)
glassExpansion = materialProgress
+ val miniAlpha = if (!miniVisible && progress == 0f) 0f else 1f - blend(0.35f, 0.6f)
+ val hideTransparentMiniGlass = miniAlpha <= 0f
+ var revivedMiniGlass = false
miniGlass?.let { glass ->
- val sheet = glass.parent as? FrameLayout
- if (sheet != null && sheet !== miniRoot) {
- if (miniVisible && progress == 0f) {
- val miniPosition = IntArray(2).also { miniRoot?.getLocationInWindow(it) }
- val sheetPosition = IntArray(2).also(sheet::getLocationInWindow)
- miniOffsetInSheet = miniPosition[1] - sheetPosition[1]
- }
- val margin = (dp(16) * (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)
- val params = glass.layoutParams as FrameLayout.LayoutParams
- if (params.height != height || params.topMargin != top || params.leftMargin != margin) {
- params.height = height; params.topMargin = top
- params.leftMargin = margin; params.rightMargin = margin
- glass.layoutParams = params
+ if (!hideTransparentMiniGlass) {
+ val sheet = glass.parent as? FrameLayout
+ if (sheet != null && sheet !== miniRoot) {
+ if (miniVisible && progress == 0f) {
+ val miniPosition = IntArray(2).also { miniRoot?.getLocationInWindow(it) }
+ val sheetPosition = IntArray(2).also(sheet::getLocationInWindow)
+ miniOffsetInSheet = miniPosition[1] - sheetPosition[1]
+ }
+ // Keep the tablet's original capsule anchor, but stop the
+ // horizontal morph at 80% of the sheet width. This reduces
+ // the fading surface area without changing its timing.
+ val sideInset = if (geometry.sideBySide) sheet.width * 0.10f * materialProgress else 0f
+ val left = (miniMarginPx[0] * (1f - materialProgress) + sideInset).roundToInt()
+ val right = (miniMarginPx[1] * (1f - materialProgress) + sideInset).roundToInt()
+ val top = (miniOffsetInSheet * (1f - materialProgress)).roundToInt()
+ val collapsedHeight = dp(geometry.miniHeightDp)
+ val height = (collapsedHeight + (sheet.height - collapsedHeight) * progress).roundToInt().coerceAtLeast(collapsedHeight)
+ val params = glass.layoutParams as FrameLayout.LayoutParams
+ if (params.height != height || params.topMargin != top || params.leftMargin != left || params.rightMargin != right) {
+ params.height = height; params.topMargin = top
+ params.leftMargin = left; params.rightMargin = right
+ glass.layoutParams = params
+ }
}
}
- glass.alpha = if (!miniVisible && progress == 0f) 0f else 1f - blend(0.35f, 0.6f)
+ if (glass.alpha != miniAlpha) glass.alpha = miniAlpha
+ val visibility = if (hideTransparentMiniGlass) View.GONE else View.VISIBLE
+ if (glass.visibility != visibility) {
+ revivedMiniGlass = visibility == View.VISIBLE
+ glass.visibility = visibility
+ }
}
find("player_sheet_container")?.let { v ->
val original = save(v)
val desired = if (progress == 0f) null else original.outlineProvider
if (v.outlineProvider !== desired) v.outlineProvider = desired
}
- listOf("player_top_shadow", "background_layers", "player_fragments_host").mapNotNull(::find).forEach { v ->
+ listOf("player_top_shadow", "background_layers").mapNotNull(::find).forEach { v ->
applyLayerAlpha(v, materialProgress)
}
+ find("player_fragments_host")?.let { v ->
+ applyLayerAlpha(v, playerFragmentsAlphaFactor(progress, materialProgress))
+ }
// The motion subtree includes rectangular legibility/blur overlays and can
// still have thumbnail-sized bounds early in the native transition. Reveal
// it only after the glass has faded and the opaque player background is back.
@@ -510,11 +661,51 @@ internal class PhoneGlassSession(
applyLayerAlpha(v, blend(0.6f, 0.85f))
}
find("player_root")?.background = if (materialProgress < 1f) null else states[find("player_root")]?.background
+ // The flat tablet row is parked offscreen at 60%; the stacked phone row
+ // moves under native control, so test its actual window bounds instead.
+ val consumerVisible = !(geometry.sideBySide && progress >= 0.6f) &&
+ ((revivedMiniGlass && miniAlpha > 0f) ||
+ backdropConsumerVisible(navGlass) || backdropConsumerVisible(navScrim) ||
+ backdropConsumerVisible(miniGlass))
+ glassConsumersVisible = consumerVisible
+ // On the way back, capture the source while native player content still
+ // covers the screen. The mini glass stays GONE until the original 60% boundary.
+ val prewarmCapture = !consumerVisible && returningToMini && progress in 0.6f..0.85f
+ val captureResumed = backdrop?.setCaptureEnabled(consumerVisible || prewarmCapture) == true
+ return revivedMiniGlass || (consumerVisible && captureResumed)
}
- fun onSlide(progress: Float) { slide = progress.coerceIn(0f, 1f) }
+ private fun backdropConsumerVisible(view: View?): Boolean {
+ if (view == null || !view.isAttachedToWindow || !view.isShown || view.width <= 0 || view.height <= 0) return false
+ var ancestor: View? = view
+ while (ancestor != null) {
+ if (ancestor.alpha <= 0f) return false
+ ancestor = ancestor.parent as? View
+ }
+ if (!view.getGlobalVisibleRect(visibleGlassRect)) return false
+ // clipChildren=false can report an offscreen row as globally visible.
+ // Its translated screen bounds must still intersect this window.
+ view.getLocationOnScreen(visibleGlassLocation)
+ val window = activity.window.decorView
+ window.getLocationOnScreen(windowLocation)
+ return visibleGlassLocation[0] < windowLocation[0] + window.width &&
+ visibleGlassLocation[0] + view.width > windowLocation[0] &&
+ visibleGlassLocation[1] < windowLocation[1] + window.height &&
+ visibleGlassLocation[1] + view.height > windowLocation[1]
+ }
+
+ private fun canRefreshBackdrop(): Boolean = source?.let {
+ it.isAttachedToWindow && it.width > 0 && it.height > 0
+ } == true
+
+ override fun onSlide(progress: Float) {
+ val next = progress.coerceIn(0f, 1f)
+ if (next < slide - 0.001f) returningToMini = true
+ else if (next > slide + 0.001f) returningToMini = false
+ slide = next
+ }
- 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,10 +720,17 @@ 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
+ // Only the tablet form narrows the full-width host touch surfaces.
+ override fun shouldPassThroughTouch(view: View, event: MotionEvent): Boolean = false
+
+ override fun shouldBypassPlayerIntercept(event: MotionEvent): Boolean = false
+
+ override fun dispatchCollapsedMiniTouch(view: View, event: MotionEvent): Boolean? = null
+
private fun miniGlassPosition(event: MotionEvent): Pair? {
val glass = miniGlass ?: return null
if (glass.width <= 0 || glass.height <= 0) return null
@@ -544,7 +742,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 +756,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) }
}
@@ -574,6 +773,7 @@ internal class PhoneGlassSession(
if (closed) return
closed = true
activated = false
+ playerSheet = null
attachHandler.removeCallbacks(retryAttach)
retryPending = false
observer?.takeIf { it.isAlive }?.removeOnPreDrawListener(this)
@@ -585,6 +785,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()
}
@@ -617,6 +818,10 @@ internal class PhoneGlassSession(
private val clipPadding = (view as? ViewGroup)?.clipToPadding
val outlineProvider = view.outlineProvider
private val transform = floatArrayOf(view.scaleX, view.scaleY, view.translationX, view.translationY)
+ fun restoreInteraction(view: View) {
+ if (view.visibility != visibility) view.visibility = visibility
+ if (view.importantForAccessibility != accessibility) view.importantForAccessibility = accessibility
+ }
fun restoreScroll(view: View) {
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, padding[3])
if (view is ViewGroup) clipPadding?.let { view.clipToPadding = it }
diff --git a/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt
new file mode 100644
index 00000000..938bd801
--- /dev/null
+++ b/app/src/main/java/dev/amenhancer/module/hook/TabletDualPaneGlassSession.kt
@@ -0,0 +1,240 @@
+package dev.amenhancer.module.hook
+
+import android.app.Activity
+import android.view.MotionEvent
+import android.view.View
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.annotation.RequiresApi
+import dev.amenhancer.glass.GlassCapsuleBounds
+import dev.amenhancer.glass.GlassGeometry
+import dev.amenhancer.glass.GlassHostView
+import dev.amenhancer.glass.GlassPolicy
+import dev.amenhancer.glass.TabletGlassLayoutPolicy
+import dev.amenhancer.glass.TabletGlassGestureGate
+import dev.amenhancer.module.config.TargetConfigClient
+import kotlin.math.abs
+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) {
+
+ private val touchGate = TabletGlassGestureGate()
+ private var miniPressDownTime: Long? = null
+ private var redirectedMiniDownTime: Long? = null
+ private var redirectedMiniTarget: FrameLayout? = null
+ private var artworkAnchorView: View? = null
+ private var artworkStartOffsetY: Float? = null
+
+ private data class CapsuleHit(val any: Boolean, val mini: Boolean)
+
+ override val geometry: GlassGeometry get() = GlassGeometry.Tablet
+
+ // Reveal the native cover at the first slide only after its alignment hook
+ // has identified this player's artwork. Other host builds keep the stock fade.
+ protected override fun playerFragmentsAlphaFactor(progress: Float, materialProgress: Float): Float =
+ if (progress > 0f && artworkAnchorView === find("fullplayerSongImage")) 1f else materialProgress
+
+ // User sketch (2026-09-22): both capsules share one bottom row — the nav
+ // pill on the left (65% of a two-thirds-wide row), the mini pill in the
+ // right slot (30%), a small gap between them, and equal outer whitespace
+ // ("留白长度一致"): 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)
+
+ /** Keep Apple's scaled artwork inside the opening mini glass. */
+ internal fun alignNativeArtworkStart(artwork: View, slide: Float) {
+ if (!activated || !slide.isFinite() || artwork !== find("fullplayerSongImage")) return
+ val container = artwork.parent as? View ?: return
+ if (container.id != resourceId("artwork_container", "id")) return
+ if (artworkAnchorView !== artwork) {
+ artworkAnchorView = artwork
+ artworkStartOffsetY = null
+ }
+ val progress = slide.coerceIn(0f, 1f)
+ val miniCover = miniRoot?.findViewById(resourceId("video_surface_container", "id"))
+ if (progress <= 0.001f && artwork.scaleY < 0.2f) {
+ if (miniCover != null && miniCover.width > 0 && miniCover.height > 0) {
+ val source = IntArray(2).also(miniCover::getLocationOnScreen)
+ val target = IntArray(2).also(artwork::getLocationOnScreen)
+ artworkStartOffsetY = (source[1] - target[1]).toFloat()
+ }
+ }
+ // offsetDescendantRectToMyCoords in the native callback omits the
+ // dual-pane artwork_container's visual translation. This shift restores
+ // the thumbnail's actual screen origin and fades out at the full view.
+ val sourceCorrection = artworkStartOffsetY ?: -container.translationY
+ artwork.translationY += sourceCorrection * (1f - progress)
+ if (progress > 0f && miniCover != null && miniCover.width > 0 && miniCover.height > 0) {
+ val source = IntArray(2).also(miniCover::getLocationOnScreen)
+ val target = IntArray(2).also(artwork::getLocationOnScreen)
+ // Apple's full cover can run above the glass while the sheet is still
+ // opening. Its native scale and horizontal motion remain untouched.
+ if (target[1] < source[1]) artwork.translationY += (source[1] - target[1]).toFloat()
+ }
+ }
+
+ /** Both native touch owners are full-width; only the rendered capsules accept a down. */
+ override fun shouldPassThroughTouch(view: View, event: MotionEvent): Boolean {
+ if (view !== hostRoot && view !== playerSheet && view !== miniRoot) return false
+ return passesThrough(event)
+ }
+
+ override fun shouldBypassPlayerIntercept(event: MotionEvent): Boolean = passesThrough(event)
+
+ override fun dispatchCollapsedMiniTouch(view: View, event: MotionEvent): Boolean? {
+ if (view.id != resourceId("player_root", "id") || view !== find("player_root")) return null
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
+ redirectedMiniTarget = miniRoot?.takeIf {
+ activated && isCollapsed && it.isShown && capsuleHit(event)?.mini == true
+ }
+ redirectedMiniDownTime = event.downTime.takeIf { redirectedMiniTarget != null }
+ }
+ val target = redirectedMiniTarget?.takeIf { redirectedMiniDownTime == event.downTime } ?: return null
+ val location = IntArray(2).also(target::getLocationOnScreen)
+ val forwarded = MotionEvent.obtain(event)
+ forwarded.setLocation(event.rawX - location[0], event.rawY - location[1])
+ return try {
+ target.dispatchTouchEvent(forwarded)
+ } finally {
+ forwarded.recycle()
+ if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
+ redirectedMiniTarget = null
+ redirectedMiniDownTime = null
+ }
+ }
+ }
+
+ private fun passesThrough(event: MotionEvent): Boolean {
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
+ val accepted = !activated || !isCollapsed || !glassMenuReady || capsuleHit(event)?.any != false
+ return touchGate.start(event.downTime, hitCapsule = accepted)
+ }
+ // Keep the initial target for the whole gesture, including a move into a capsule.
+ // The next DOWN resets this latch; multiple hooks may see the same UP/CANCEL.
+ return touchGate.isPassedThrough(event.downTime)
+ }
+
+ override fun observeTouch(event: MotionEvent) {
+ if (event.actionMasked == MotionEvent.ACTION_DOWN) {
+ miniPressDownTime = event.downTime.takeIf { capsuleHit(event)?.mini == true }
+ }
+ if (miniPressDownTime == event.downTime) super.observeTouch(event)
+ if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) {
+ miniPressDownTime = null
+ }
+ }
+
+ private fun capsuleHit(event: MotionEvent): CapsuleHit? {
+ val frame = navFrame ?: return null
+ val navigation = navGlass?.takeIf { it.isShown && it.width > 0 && it.height > 0 }
+ val mini = if (miniVisible) miniGlass?.takeIf { it.isShown && it.width > 0 && it.height > 0 } else null
+ if (navigation == null && mini == null) return null
+ val origin = IntArray(2).also(frame::getLocationOnScreen)
+ val x = event.rawX - origin[0]
+ val y = event.rawY - origin[1]
+ fun bounds(view: View): GlassCapsuleBounds {
+ val location = IntArray(2).also(view::getLocationOnScreen)
+ val left = (location[0] - origin[0]).toFloat()
+ val top = (location[1] - origin[1]).toFloat()
+ return GlassCapsuleBounds(left, top, left + view.width, top + view.height)
+ }
+ val navBounds = navigation?.let(::bounds)
+ val miniBounds = mini?.let(::bounds)
+ val miniHit = miniBounds?.let { TabletGlassLayoutPolicy.contains(x, y, it) } == true
+ return CapsuleHit(
+ any = navBounds?.let { TabletGlassLayoutPolicy.containsEither(x, y, it, miniBounds) } ?: miniHit,
+ mini = miniHit,
+ )
+ }
+
+ // 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 elevated tabs frame sits above player_container. Keep its full-width fade
+ * below both capsules so it cannot wash over the mini player's glass. */
+ override fun attachNavigationScrim(frame: FrameLayout, scrim: GlassHostView) {
+ val container = find("player_container") as? ViewGroup
+ ?: return super.attachNavigationScrim(frame, scrim)
+ val height = frame.height.takeIf { it > 0 }
+ ?: frame.layoutParams?.height?.takeIf { it > 0 }
+ ?: GlassPolicy.occupiedHeight(density, bottomInset, miniVisible, bottomGapDp, geometry)
+ container.addView(scrim, 0, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, height))
+ }
+
+ // The flat holder never translates the tabs frame, so the capsule exit is driven here
+ // with the phone StackedBottomNavigationHolder.c exp(-20t) curve over the whole glass
+ // occupied height (navigation capsule + mini); slide back to 0 parks the capsule again.
+ override fun driveNavFrameExit(progress: Float) {
+ val frame = navFrame ?: return
+ val extent = GlassPolicy.occupiedHeight(density, bottomInset, miniVisible, bottomGapDp, geometry)
+ // Past the glass fade the exponential tail moves by less than a pixel.
+ // Park the row exactly offscreen and avoid subpixel invalidations.
+ val target = if (progress >= 0.6f) extent.toFloat() else (1f - exp(-20f * progress)) * extent
+ if (progress == 0f || progress >= 0.6f || progress < 0.35f || abs(frame.translationY - target) >= 0.5f) {
+ if (frame.translationY != target) frame.translationY = target
+ }
+ val scrim = navScrim ?: return
+ val container = scrim.parent as? ViewGroup ?: return
+ if (frame.height > 0 && scrim.layoutParams.height != frame.height) {
+ scrim.layoutParams = scrim.layoutParams.apply { height = frame.height }
+ }
+ val baseOffset = frame.top - (container.top + scrim.top)
+ val scrimShift = frame.translationY + baseOffset
+ if (scrim.translationY != scrimShift) scrim.translationY = scrimShift
+ }
+
+ // The dual-pane boundary sync mutes its own writes while the glass owns the geometry.
+ 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/hook/TargetSymbols.kt b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt
index a31bb80d..9dc654ff 100644
--- a/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt
+++ b/app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt
@@ -238,6 +238,7 @@ internal data class AppleMusicProfile(
internal enum class TargetSymbolId {
PLAYER_CONTROLLER,
+ PLAYER_ARTWORK_SLIDE_CALLBACK,
PLAYER_ACTIVITY,
PLAYER_ACTIVITY_CREATE_STACKED_NAVIGATION_HOLDER,
PLAYER_ACTIVITY_ROOT,
@@ -364,6 +365,7 @@ private object AppleMusicProfiles {
id = "apple-music-6.5.3-1599",
exactClasses = mapOf(
TargetSymbolId.PLAYER_CONTROLLER to "com.apple.android.music.player.fragment.v0",
+ TargetSymbolId.PLAYER_ARTWORK_SLIDE_CALLBACK to "com.apple.android.music.player.fragment.v0\$k",
TargetSymbolId.PLAYER_ACTIVITY to "com.apple.android.music.common.activity.PlayerActivity",
TargetSymbolId.EDITORIAL_VIDEO_OWNER to "com.apple.android.music.player.f1",
TargetSymbolId.LYRICS_FRAGMENT to "com.apple.android.music.player.fragment.PlayerLyricsViewFragment",
@@ -409,6 +411,7 @@ private object AppleMusicProfiles {
id = "apple-music-6.5.2-1586",
exactClasses = mapOf(
TargetSymbolId.PLAYER_CONTROLLER to "com.apple.android.music.player.fragment.t0",
+ TargetSymbolId.PLAYER_ARTWORK_SLIDE_CALLBACK to "com.apple.android.music.player.fragment.t0\$k",
TargetSymbolId.PLAYER_ACTIVITY to "com.apple.android.music.common.activity.PlayerActivity",
TargetSymbolId.EDITORIAL_VIDEO_OWNER to "com.apple.android.music.player.f1",
TargetSymbolId.LYRICS_FRAGMENT to "com.apple.android.music.player.fragment.PlayerLyricsViewFragment",
@@ -464,6 +467,10 @@ private object AppleMusicProfiles {
}
internal object AppleMusicSymbols {
+ /** The verified BottomSheet callback that owns cover alignment on each glass-supported build. */
+ fun playerArtworkSlideCallbackClassName(build: TargetBuild): String? =
+ AppleMusicProfiles.match(build)?.exactClasses?.get(TargetSymbolId.PLAYER_ARTWORK_SLIDE_CALLBACK)
+
/**
* Apple Music 6.5.2/1586's karaoke transition entry point
* (`com.apple.android.music.player.z.a0(z$a, int, int, int, boolean)`).
diff --git a/app/src/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/AppleMusic653ProfileTest.kt b/app/src/test/java/dev/amenhancer/module/hook/AppleMusic653ProfileTest.kt
index 4d113edb..cd5e68db 100644
--- a/app/src/test/java/dev/amenhancer/module/hook/AppleMusic653ProfileTest.kt
+++ b/app/src/test/java/dev/amenhancer/module/hook/AppleMusic653ProfileTest.kt
@@ -22,6 +22,23 @@ class AppleMusic653ProfileTest {
private val build653 = TargetBuild(ModuleConstants.TARGET_PACKAGE, "6.5.3", 1599L)
private val version653 = AppleMusicVersion("6.5.3", 1599L)
+ @Test
+ fun `glass artwork slide callback follows the selected build profile`() {
+ assertEquals(
+ "com.apple.android.music.player.fragment.t0\$k",
+ AppleMusicSymbols.playerArtworkSlideCallbackClassName(
+ TargetBuild(ModuleConstants.TARGET_PACKAGE, "6.5.2", 1586L),
+ ),
+ )
+ assertEquals(
+ "com.apple.android.music.player.fragment.v0\$k",
+ AppleMusicSymbols.playerArtworkSlideCallbackClassName(build653),
+ )
+ assertNull(AppleMusicSymbols.playerArtworkSlideCallbackClassName(
+ TargetBuild(ModuleConstants.TARGET_PACKAGE, "6.5.3", 1586L),
+ ))
+ }
+
@Test
fun `bootstrap accepts only the exact 6_5_3 tuple`() {
val bootstrap = EmbeddedBootstrap()
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..8c58589b
--- /dev/null
+++ b/app/src/test/java/dev/amenhancer/module/hook/TabletLiquidGlassStructuralRegressionTest.kt
@@ -0,0 +1,143 @@
+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")
+ val runtime = source("dev/amenhancer/module/hook/PhoneGlassRuntime.kt")
+ // Hide native tabs while glass owns the menu, but restore them if the
+ // visible menu cannot be mapped. Blank-area gestures pass through.
+ assertTrue(base.contains("hideSeam(nav)"))
+ assertTrue(base.contains("restoreInteraction(nav)"))
+ assertTrue(session.contains("!glassMenuReady"))
+ assertTrue(session.contains("TabletGlassLayoutPolicy.containsEither"))
+ assertTrue(runtime.contains("it.shouldPassThroughTouch(root, event)"))
+ assertTrue(runtime.contains("it.shouldBypassPlayerIntercept(event)"))
+ // Apple binds both native click and long-click to mini_player_touch_panel.
+ assertTrue(!session.contains("setOnClickListener"))
+ 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/backdrop/UPSTREAM.md b/backdrop/UPSTREAM.md
index 3b885769..fac5c8c8 100644
--- a/backdrop/UPSTREAM.md
+++ b/backdrop/UPSTREAM.md
@@ -5,16 +5,16 @@ AndroidLiquidGlass / Backdrop 2.0.1, by Kyant.
- Upstream: https://github.com/Kyant0/AndroidLiquidGlass
- Pinned commit: `65ab177e90e5c1d8c62e70cf7755841982da65f6`
- License: Apache-2.0; see LICENSE in this directory.
-- `src/commonMain` and the original `src/androidMain` files are copied without code edits. `upstream-sha256.json` records SHA256 with CRLF normalized to LF, matching the repository's cross-platform Git checkout rules.
+- `src/commonMain` and the original `src/androidMain` files come from upstream. `DrawBackdropModifier.kt` has one AM++ change: configurable clip compositing for the tablet mini-player. `upstream-sha256.json` retains the original SHA256 values; `local-patches-sha256.json` pins the modified file. Both normalize CRLF to LF for cross-platform checkouts.
- `src/androidMain/.../backdrops/ViewBackdrop.kt` is an AM++ addition for a hardware-recorded Android View source.
- Build configuration targets Android only and uses the application's Java 17 baseline. Desktop, browser, Apple targets and publication tasks are not included.
-Run `python scripts/verify-glass-reference.py` from the repository root to verify original rendering sources.
+Run `python scripts/verify-glass-reference.py` from the repository root to verify the original rendering sources and the declared local patch.
Every file AM++ modified, and every file AM++ added inside the upstream
packages, carries a header naming the upstream commit, the Apache-2.0 license
-and the change itself, as Apache-2.0 section 4(b) requires. The pristine files
-listed in `upstream-sha256.json` are left byte-identical apart from line
-endings.
+and the change itself, as Apache-2.0 section 4(b) requires. Files without an
+entry in `local-patches-sha256.json` remain byte-identical to the upstream
+hashes apart from line endings.
The seven catalog component/helper files in `glass/src/main/kotlin/com/kyant/backdrop/catalog` have the same source commit and license. AM++ changes: optional host accent and tap-only native reselection in LiquidBottomTabs/DampedDragAnimation, Android-only awaitFrame helper, and native touch entry points on InteractiveHighlight. Original shaders, springs and effect ordering are retained. NativeLiquidButton adapts LiquidButton's material and transform to content whose View/actions remain owned by Apple Music.
diff --git a/backdrop/local-patches-sha256.json b/backdrop/local-patches-sha256.json
new file mode 100644
index 00000000..b22322ce
--- /dev/null
+++ b/backdrop/local-patches-sha256.json
@@ -0,0 +1,3 @@
+{
+ "src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt": "437de087c27a528f11ff2c5d3dba98317464dcf737c9bf79ac00a5d19fc044a3"
+}
diff --git a/backdrop/src/androidMain/kotlin/com/kyant/backdrop/backdrops/ViewBackdrop.kt b/backdrop/src/androidMain/kotlin/com/kyant/backdrop/backdrops/ViewBackdrop.kt
index 42a9f448..8e6d4837 100644
--- a/backdrop/src/androidMain/kotlin/com/kyant/backdrop/backdrops/ViewBackdrop.kt
+++ b/backdrop/src/androidMain/kotlin/com/kyant/backdrop/backdrops/ViewBackdrop.kt
@@ -25,6 +25,7 @@ import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.unit.Density
import com.kyant.backdrop.Backdrop
+import kotlin.math.abs
/** AM++ Android View bridge. The source must NOT contain any consumer of this backdrop. */
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@@ -44,6 +45,7 @@ class ViewBackdrop(
private var observer: ViewTreeObserver? = null
private var recording = false
private var closed = false
+ private var captureEnabled = true
var ready: Boolean = false
private set
var recordings: Long = 0
@@ -57,13 +59,24 @@ class ViewBackdrop(
observer = source.viewTreeObserver.also { it.addOnPreDrawListener(this) }
}
+ /** Suspend source recording while no backdrop consumer is visible. */
+ fun setCaptureEnabled(enabled: Boolean): Boolean {
+ if (closed || captureEnabled == enabled) return false
+ captureEnabled = enabled
+ if (enabled) {
+ ready = false
+ source.postInvalidateOnAnimation()
+ }
+ return enabled // The caller can defer this draw until the fresh capture.
+ }
+
override fun onPreDraw(): Boolean {
- if (closed || recording || !source.isAttachedToWindow || source.width == 0 || source.height == 0) return true
+ if (closed || !captureEnabled || recording || !source.isAttachedToWindow || source.width == 0 || source.height == 0) return true
try {
check(source.isHardwareAccelerated) { "Hardware accelerated window required" }
updateSourceMatrix()
sourceToWindow.getValues(matrixValues)
- val moved = !matrixValues.contentEquals(previousMatrix)
+ val moved = hasSignificantMatrixChange()
if (!ready || source.isDirty || moved || node.width != source.width || node.height != source.height) {
recording = true
node.setPosition(0, 0, source.width, source.height)
@@ -87,6 +100,17 @@ class ViewBackdrop(
return true
}
+ // Compare with the last recorded matrix so subpixel movement accumulates
+ // until it crosses the threshold; content changes still trigger a capture.
+ private fun hasSignificantMatrixChange(): Boolean = matrixValues.indices.any { index ->
+ if (index == Matrix.MTRANS_X || index == Matrix.MTRANS_Y) {
+ val delta = abs(matrixValues[index] - previousMatrix[index])
+ !delta.isFinite() || delta >= 0.5f
+ } else {
+ matrixValues[index] != previousMatrix[index]
+ }
+ }
+
/** Transparent Compose scenes must include the window underneath them. Otherwise
* blurred alpha blends over the still-sharp scene, defeating the blur. */
private fun drawWindowBackground(canvas: android.graphics.Canvas) {
diff --git a/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt b/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt
index d91d6adc..a1d3bd08 100644
--- a/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt
+++ b/backdrop/src/commonMain/kotlin/com/kyant/backdrop/DrawBackdropModifier.kt
@@ -1,3 +1,12 @@
+/*
+ * Derived from AndroidLiquidGlass / Backdrop 2.0.1
+ * (https://github.com/Kyant0/AndroidLiquidGlass), commit
+ * 65ab177e90e5c1d8c62e70cf7755841982da65f6, Apache License 2.0.
+ * Changed by AM++: allow the tablet mini-player to use automatic clip compositing
+ * while existing callers keep the original offscreen compositing strategy.
+ * See backdrop/UPSTREAM.md and THIRD_PARTY_NOTICES.md.
+ */
+
package com.kyant.backdrop
import androidx.compose.runtime.getValue
@@ -85,6 +94,8 @@ fun Modifier.drawBackdrop(
shadow: (() -> Shadow?)? = DefaultShadow,
innerShadow: (() -> InnerShadow?)? = null,
layerBlock: (GraphicsLayerScope.() -> Unit)? = null,
+ // Existing callers retain the offscreen clip; the large tablet mini surface can opt into Auto.
+ clipCompositingStrategy: androidx.compose.ui.graphics.CompositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen,
exportedBackdrop: LayerBackdrop? = null,
onDrawBehind: (DrawScope.() -> Unit)? = null,
onDrawBackdrop: DrawScope.(drawBackdrop: DrawScope.() -> Unit) -> Unit = DefaultOnDrawBackdrop,
@@ -136,6 +147,7 @@ fun Modifier.drawBackdrop(
shapeProvider = shapeProvider,
effects = effects,
layerBlock = layerBlock,
+ clipCompositingStrategy = clipCompositingStrategy,
exportedBackdrop = exportedBackdrop,
onDrawBehind = onDrawBehind,
onDrawBackdrop = onDrawBackdrop,
@@ -150,6 +162,7 @@ private class DrawBackdropElement(
val shapeProvider: ShapeProvider,
val effects: BackdropEffectScope.() -> Unit,
val layerBlock: (GraphicsLayerScope.() -> Unit)?,
+ val clipCompositingStrategy: androidx.compose.ui.graphics.CompositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen,
val exportedBackdrop: LayerBackdrop?,
val onDrawBehind: (DrawScope.() -> Unit)?,
val onDrawBackdrop: DrawScope.(drawBackdrop: DrawScope.() -> Unit) -> Unit,
@@ -163,6 +176,7 @@ private class DrawBackdropElement(
shapeProvider = shapeProvider,
effects = effects,
layerBlock = layerBlock,
+ clipCompositingStrategy = clipCompositingStrategy,
exportedBackdrop = exportedBackdrop,
onDrawBehind = onDrawBehind,
onDrawBackdrop = onDrawBackdrop,
@@ -176,6 +190,7 @@ private class DrawBackdropElement(
node.shapeProvider = shapeProvider
node.effects = effects
node.layerBlock = layerBlock
+ node.clipCompositingStrategy = clipCompositingStrategy
if (node.exportedBackdrop != exportedBackdrop) {
node.exportedBackdrop?.layerCoordinates = null
node.exportedBackdrop = exportedBackdrop
@@ -193,6 +208,7 @@ private class DrawBackdropElement(
properties["shapeProvider"] = shapeProvider
properties["effects"] = effects
properties["layerBlock"] = layerBlock
+ properties["clipCompositingStrategy"] = clipCompositingStrategy
properties["exportedBackdrop"] = exportedBackdrop
properties["onDrawBehind"] = onDrawBehind
properties["onDrawBackdrop"] = onDrawBackdrop
@@ -208,6 +224,7 @@ private class DrawBackdropElement(
if (shapeProvider != other.shapeProvider) return false
if (effects != other.effects) return false
if (layerBlock != other.layerBlock) return false
+ if (clipCompositingStrategy != other.clipCompositingStrategy) return false
if (exportedBackdrop != other.exportedBackdrop) return false
if (onDrawBehind != other.onDrawBehind) return false
if (onDrawBackdrop != other.onDrawBackdrop) return false
@@ -222,6 +239,7 @@ private class DrawBackdropElement(
result = 31 * result + shapeProvider.hashCode()
result = 31 * result + effects.hashCode()
result = 31 * result + (layerBlock?.hashCode() ?: 0)
+ result = 31 * result + clipCompositingStrategy.hashCode()
result = 31 * result + (exportedBackdrop?.hashCode() ?: 0)
result = 31 * result + (onDrawBehind?.hashCode() ?: 0)
result = 31 * result + onDrawBackdrop.hashCode()
@@ -236,6 +254,7 @@ private class DrawBackdropNode(
var shapeProvider: ShapeProvider,
var effects: BackdropEffectScope.() -> Unit,
var layerBlock: (GraphicsLayerScope.() -> Unit)?,
+ var clipCompositingStrategy: androidx.compose.ui.graphics.CompositingStrategy,
var exportedBackdrop: LayerBackdrop?,
var onDrawBehind: (DrawScope.() -> Unit)?,
var onDrawBackdrop: DrawScope.(drawBackdrop: DrawScope.() -> Unit) -> Unit,
@@ -254,7 +273,7 @@ private class DrawBackdropNode(
private val layoutLayerBlock: GraphicsLayerScope.() -> Unit = {
clip = true
shape = shapeProvider.shape
- compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen
+ compositingStrategy = clipCompositingStrategy
}
private var layoutCoordinates: LayoutCoordinates? by mutableStateOf(null, neverEqualPolicy())
diff --git a/glass/src/main/kotlin/dev/amenhancer/glass/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/main/kotlin/dev/amenhancer/glass/NativeLiquidButton.kt b/glass/src/main/kotlin/dev/amenhancer/glass/NativeLiquidButton.kt
index a0415f4f..4419031d 100644
--- a/glass/src/main/kotlin/dev/amenhancer/glass/NativeLiquidButton.kt
+++ b/glass/src/main/kotlin/dev/amenhancer/glass/NativeLiquidButton.kt
@@ -41,6 +41,9 @@ fun NativeLiquidButton(
input: NativeButtonInput,
expansion: Float = 0f,
panelBlur: Dp = GlassPolicy.PANEL_BLUR_DP.dp,
+ /** Let Compose choose the compositing path for the tablet mini surface. */
+ autoClip: Boolean = false,
+ miniHeightDp: Int = GlassPolicy.MINI_HEIGHT_DP,
transformContent: (Float, Float, Float, Float) -> Unit,
) {
val containerColor = if (isSystemInDarkTheme()) Color(0xFF121212).copy(alpha = 0.4f)
@@ -60,7 +63,9 @@ fun NativeLiquidButton(
Box(
Modifier.fillMaxSize().drawBackdrop(
backdrop = backdrop,
- shape = { if (expansion == 0f) Capsule() else RoundedCornerShape(lerp(GlassPolicy.MINI_HEIGHT_DP / 2f, 24f, expansion).dp) },
+ clipCompositingStrategy = if (autoClip) androidx.compose.ui.graphics.CompositingStrategy.Auto
+ else androidx.compose.ui.graphics.CompositingStrategy.Offscreen,
+ shape = { if (expansion == 0f) Capsule() else RoundedCornerShape(lerp(miniHeightDp / 2f, 24f, expansion).dp) },
effects = {
vibrancy()
blur(panelBlur.toPx())
diff --git a/glass/src/main/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicy.kt b/glass/src/main/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicy.kt
new file mode 100644
index 00000000..280aa4d2
--- /dev/null
+++ b/glass/src/main/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicy.kt
@@ -0,0 +1,49 @@
+package dev.amenhancer.glass
+
+import kotlin.math.min
+
+/** Bounds of a rendered capsule in the bottom bar's local coordinate space. */
+data class GlassCapsuleBounds(
+ val left: Float,
+ val top: Float,
+ val right: Float,
+ val bottom: Float,
+)
+
+/** The tablet's touch region follows the visible capsules, including their round ends. */
+object TabletGlassLayoutPolicy {
+ fun contains(x: Float, y: Float, capsule: GlassCapsuleBounds): Boolean {
+ if (x < capsule.left || x >= capsule.right || y < capsule.top || y >= capsule.bottom) return false
+ val radius = min(capsule.right - capsule.left, capsule.bottom - capsule.top) / 2f
+ if (radius <= 0f) return false
+ val centerX = x.coerceIn(capsule.left + radius, capsule.right - radius)
+ val centerY = y.coerceIn(capsule.top + radius, capsule.bottom - radius)
+ val dx = x - centerX
+ val dy = y - centerY
+ return dx * dx + dy * dy <= radius * radius
+ }
+
+ fun containsEither(
+ x: Float,
+ y: Float,
+ navigation: GlassCapsuleBounds,
+ miniPlayer: GlassCapsuleBounds?,
+ ): Boolean = contains(x, y, navigation) ||
+ (miniPlayer != null && contains(x, y, miniPlayer))
+}
+
+/** A gesture keeps the owner chosen by its first DOWN, even after it moves. */
+class TabletGlassGestureGate {
+ private var currentDownTime: Long? = null
+ private var passedThroughDownTime: Long? = null
+
+ fun start(downTime: Long, hitCapsule: Boolean): Boolean {
+ if (currentDownTime != downTime) {
+ currentDownTime = downTime
+ passedThroughDownTime = downTime.takeUnless { hitCapsule }
+ }
+ return isPassedThrough(downTime)
+ }
+
+ fun isPassedThrough(downTime: Long): Boolean = passedThroughDownTime == downTime
+}
diff --git a/glass/src/test/kotlin/dev/amenhancer/glass/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))
+ }
+}
diff --git a/glass/src/test/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicyTest.kt b/glass/src/test/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicyTest.kt
new file mode 100644
index 00000000..a9cee7ed
--- /dev/null
+++ b/glass/src/test/kotlin/dev/amenhancer/glass/TabletGlassLayoutPolicyTest.kt
@@ -0,0 +1,39 @@
+package dev.amenhancer.glass
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class TabletGlassLayoutPolicyTest {
+ private val navigation = GlassCapsuleBounds(100f, 20f, 360f, 76f)
+ private val mini = GlassCapsuleBounds(380f, 20f, 500f, 76f)
+
+ @Test fun `only the two rounded capsules own touches`() {
+ assertTrue(TabletGlassLayoutPolicy.containsEither(120f, 48f, navigation, mini))
+ assertTrue(TabletGlassLayoutPolicy.containsEither(480f, 48f, navigation, mini))
+ assertFalse(TabletGlassLayoutPolicy.containsEither(370f, 48f, navigation, mini))
+ assertFalse(TabletGlassLayoutPolicy.containsEither(90f, 48f, navigation, mini))
+ assertFalse(TabletGlassLayoutPolicy.containsEither(450f, 15f, navigation, mini))
+ }
+
+ @Test fun `capsule bounding-box corners are not touchable`() {
+ assertFalse(TabletGlassLayoutPolicy.contains(101f, 21f, navigation))
+ assertFalse(TabletGlassLayoutPolicy.contains(499f, 21f, mini))
+ assertTrue(TabletGlassLayoutPolicy.contains(100f, 48f, navigation))
+ assertTrue(TabletGlassLayoutPolicy.contains(499f, 48f, mini))
+ }
+
+ @Test fun `missing mini player leaves no phantom hit region`() {
+ assertFalse(TabletGlassLayoutPolicy.containsEither(440f, 48f, navigation, null))
+ }
+
+ @Test fun `gesture owner follows the first down`() {
+ val gate = TabletGlassGestureGate()
+ assertTrue(gate.start(10L, hitCapsule = false))
+ assertTrue(gate.start(10L, hitCapsule = true)) // A second hook sees the same DOWN.
+ assertTrue(gate.isPassedThrough(10L))
+ assertFalse(gate.start(20L, hitCapsule = true))
+ assertFalse(gate.isPassedThrough(20L))
+ assertFalse(gate.isPassedThrough(10L))
+ }
+}
diff --git a/scripts/verify-glass-reference.py b/scripts/verify-glass-reference.py
index c2f576aa..75505d0b 100644
--- a/scripts/verify-glass-reference.py
+++ b/scripts/verify-glass-reference.py
@@ -1,15 +1,24 @@
-"""Verify unmodified vendored rendering sources; no external packages or reference checkout needed."""
+"""Verify pinned upstream sources and declared AM++ patches without a reference checkout."""
import hashlib
import json
from pathlib import Path
root = Path(__file__).resolve().parents[1]
manifest = json.loads((root / "backdrop/upstream-sha256.json").read_text())
+local_patches = json.loads((root / "backdrop/local-patches-sha256.json").read_text())
+unexpected = local_patches.keys() - manifest.keys()
+if unexpected:
+ raise SystemExit("Unrecognized local Backdrop patches:\n" + "\n".join(sorted(unexpected)))
failed = []
-for relative, expected in manifest.items():
+for relative, upstream_hash in manifest.items():
path = root / "backdrop" / relative
+ expected = local_patches.get(relative, upstream_hash)
if not path.is_file() or hashlib.sha256(path.read_bytes().replace(b"\r\n", b"\n")).hexdigest() != expected:
failed.append(relative)
if failed:
- raise SystemExit("Upstream rendering source changed:\n" + "\n".join(failed))
-print(f"PASS: {len(manifest)} original Backdrop files match commit 65ab177e90e5c1d8c62e70cf7755841982da65f6")
+ raise SystemExit("Backdrop rendering source differs from its pinned hash:\n" + "\n".join(failed))
+print(
+ f"PASS: {len(manifest) - len(local_patches)} original Backdrop files match "
+ "commit 65ab177e90e5c1d8c62e70cf7755841982da65f6; "
+ f"{len(local_patches)} declared AM++ patch(es) match their pinned hashes"
+)