Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ee97a29
feat(glass): add tablet dual-pane host form to the liquid-glass session
Sep 23, 2026
a7a17bc
feat(dual-pane): silence flat boundary sync while tablet glass owns g…
Sep 23, 2026
fe9aec5
feat(settings): scope liquid-glass copy to phone and tablet dual-pane
Sep 23, 2026
b2fc49e
fix(glass): suppress flat chrome seams under the tablet capsule
Sep 23, 2026
9aaa871
feat(glass): shorten the tablet capsules to two thirds of the host width
Sep 23, 2026
5fcbf60
fix(glass): keep the mini content aligned with the shortened capsule
Sep 23, 2026
1a55c94
fix(dual-pane): release the compensation writes when glass takes over
Sep 23, 2026
f60f3ef
feat(glass): halve the tablet capsule width and hide the inert compen…
Sep 23, 2026
d8b0ac0
feat(glass): lay the tablet capsules side by side in one centered row
Sep 23, 2026
9e3f96a
fix(glass): take over the tablet row band touch chain
Sep 23, 2026
0934ebd
fix(glass): anchor tablet mini player touch and artwork transition
Sep 27, 2026
3831666
perf(glass): share hidden backdrop optimizations across phone and tablet
Sep 27, 2026
fa141dd
fix(glass): layer tablet bottom scrim beneath both capsules
Sep 27, 2026
eaae581
perf(glass): prewarm player backdrop and trim tablet compositing
Sep 27, 2026
09656f7
chore(release): bump version to 1.6.2
Sep 27, 2026
cf1fcbd
chore(backdrop): pin intentional clip compositing patch
Sep 27, 2026
c214962
fix(glass): restore native fallback and tablet mini alignment
Sep 27, 2026
d32f4e9
fix(glass): resolve artwork slide callback by host build
Sep 27, 2026
049d22e
fix(glass): restore stacked tablet peek baseline
Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<h1 align="center">AM++</h1>

<p align="center">
Apple Music 的 Android 增强模块:平板双栏、歌词模糊与字体、自定义歌词、歌曲名修正、手机液态玻璃底栏。
Apple Music 的 Android 增强模块:平板双栏、歌词模糊与字体、自定义歌词、歌曲名修正、液态玻璃底栏。
</p>

<p align="center">
Expand Down Expand Up @@ -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。 |

## 效果展示
Expand All @@ -84,17 +84,17 @@ AM++ 通过 libxposed API 102 注入 Apple Music(`com.apple.android.music`)
<img src="docs/images/tablet-dual-pane-player-open-source-blur.png" alt="平板横屏双栏播放器与歌词模糊" width="900">
</p>

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

<p align="center">
<img src="docs/images/liquid-glass-demo.jpg" alt="手机液态玻璃底栏与迷你播放器(主页与资料库)" width="720">
<img src="docs/images/liquid-glass-demo.jpg" alt="液态玻璃底栏与迷你播放器(主页与资料库)" width="720">
</p>

## 兼容性与限制

| 项目 | 支持范围 |
| --- | --- |
| 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)` |

Expand All @@ -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。
- 手机液态玻璃只作用于手机布局;平板和其他版本继续使用原生底栏。
- 液态玻璃底栏作用于手机布局,以及开启“平板双栏播放器”的平板横屏;平板竖屏、关闭双栏的平板和其他版本继续使用原生底栏。平板形态的真机验收仍待进行。

## 安装

Expand Down Expand Up @@ -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`)。

Expand Down Expand Up @@ -208,7 +208,7 @@ scripts/ 可选的真机回归、录屏分析与 host profile
- [x] 双向歌词模糊
- [x] 自定义歌词注入与备份恢复
- [x] 歌词字体导入与恢复
- [x] 手机液态玻璃底栏与迷你播放器
- [x] 液态玻璃底栏与迷你播放器(手机;开启双栏的平板横屏生效范围待真机验收)
- [x] 歌曲名显示修正
- [ ] 补齐 Apple Music 6.5.3 的两处降级
- [ ] 持续适配后续 Apple Music 版本
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions app/src/main/java/dev/amenhancer/module/hook/GlassSession.kt
Original file line number Diff line number Diff line change
@@ -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()
}
105 changes: 90 additions & 15 deletions app/src/main/java/dev/amenhancer/module/hook/PhoneGlassRuntime.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,7 +20,7 @@ import java.util.WeakHashMap

@RequiresApi(33)
internal object PhoneGlassRuntime {
private val sessions = WeakHashMap<Activity, PhoneGlassSession>()
private val sessions = WeakHashMap<Activity, GlassSession>()
private val failed = java.util.Collections.newSetFromMap(WeakHashMap<Activity, Boolean>())
private var applicationRegistered = false
private var hooksInstalled = false
Expand All @@ -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()
Expand All @@ -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 {
Expand All @@ -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() {
Expand Down
Loading
Loading