Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
39e9b08
feat: isolate CJK karaoke animation feature
Zennmn Aug 21, 2026
38f923c
docs: add CJK karaoke animation handoff
Zennmn Aug 21, 2026
cbe1d10
docs: correct CJK handoff commit reference
Zennmn Aug 21, 2026
b9cdf94
feat: drive CJK lyric words with AM++ ValueAnimator
Zennmn Aug 21, 2026
48613b7
perf: cache CJK lyric binding reflection
Zennmn Aug 21, 2026
781ef34
fix: honor native word text during CJK rebinding
Zennmn Aug 21, 2026
5347db2
fix: report lyric cleanup hook diagnostics
Zennmn Aug 21, 2026
eaa8eb3
Revert "fix: report lyric cleanup hook diagnostics"
Zennmn Aug 21, 2026
3bfb864
Revert "fix: honor native word text during CJK rebinding"
Zennmn Aug 21, 2026
dfba54a
Revert "perf: cache CJK lyric binding reflection"
Zennmn Aug 21, 2026
38b0062
Revert "feat: drive CJK lyric words with AM++ ValueAnimator"
Zennmn Aug 21, 2026
47de18a
fix: block merged CJK chunks from native glow
Zennmn Aug 21, 2026
114ed09
debug: add CJK native animation lifecycle probe
Zennmn Aug 21, 2026
28d4d3c
debug: trace CJK shadow cleanup
Zennmn Aug 21, 2026
27f6942
fix: restore CJK native karaoke view state
Zennmn Aug 21, 2026
e3500ef
Revert "fix: restore CJK native karaoke view state"
Zennmn Aug 21, 2026
379ac41
Revert "debug: trace CJK shadow cleanup"
Zennmn Aug 21, 2026
4155bc1
Revert "debug: add CJK native animation lifecycle probe"
Zennmn Aug 21, 2026
c346d72
fix: narrow CJK cleanup to host special end
Zennmn Aug 21, 2026
c22e670
fix: bind CJK end cleanup to current lyric entry
Zennmn Aug 21, 2026
1ddce4a
experiment: build CJK words with ordinary karaoke layout
Zennmn Aug 21, 2026
2ba4507
Revert "experiment: build CJK words with ordinary karaoke layout"
Zennmn Aug 21, 2026
0af0a40
experiment: route CJK through ordinary karaoke lifecycle
Zennmn Aug 21, 2026
5535007
Revert "experiment: route CJK through ordinary karaoke lifecycle"
Zennmn Aug 21, 2026
6ada2ed
fix: reset CJK glow animator at its own end
Zennmn Aug 21, 2026
d952385
experiment: reuse English-shaped CJK glow bindings
Zennmn Aug 21, 2026
a3f5ee9
Revert "experiment: reuse English-shaped CJK glow bindings"
Zennmn Aug 21, 2026
287e414
fix: restore CJK glow view at host animator end
Zennmn Aug 21, 2026
09ab82a
fix: bind CJK glow cleanup to exact animator
Zennmn Aug 21, 2026
ef341fd
docs: update CJK glow cleanup handoff
Zennmn Aug 21, 2026
88a2b49
perf: trim CJK karaoke hook overhead
Zennmn Aug 21, 2026
d590210
fix: keep CJK glow cleanup out of lyric position
Zennmn Aug 22, 2026
d551b06
fix: harden CJK glow hook lifecycle
Zennmn Aug 22, 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
1 change: 1 addition & 0 deletions app/src/main/java/dev/amenhancer/module/ModuleConstants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ object ModuleConstants {
const val FEATURE_EDITORIAL_VIDEO = "editorial_video"
const val FEATURE_PHONE_LIQUID_GLASS = "phone_liquid_glass"
const val FEATURE_FUTURE_BLUR = "future_blur"
const val FEATURE_CJK_KARAOKE_ANIMATION = "cjk_karaoke_animation"
const val FEATURE_LYRICS_TYPEFACE = "lyrics_typeface"
const val FEATURE_CUSTOM_LYRICS = "custom_lyrics"
const val FEATURE_CURRENT_SONG_IDENTITY = "current_song_identity"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ internal object ModuleSettingsSchema {
default = false,
),
futureBlurEnabled = values.boolean(KEY_FUTURE_BLUR, default = true),
cjkKaraokeAnimationEnabled = values.boolean(
KEY_CJK_KARAOKE_ANIMATION_ENABLED,
default = true,
),
navigationCompensationEnabled = values.boolean(
KEY_NAVIGATION_COMPENSATION,
default = false,
Expand Down Expand Up @@ -62,6 +66,7 @@ internal object ModuleSettingsSchema {
KEY_DISABLE_EDITORIAL_VIDEO_ON_TABLET to settings.disableEditorialVideoOnTablet,
KEY_PHONE_LIQUID_GLASS to settings.phoneLiquidGlassEnabled,
KEY_FUTURE_BLUR to settings.futureBlurEnabled,
KEY_CJK_KARAOKE_ANIMATION_ENABLED to settings.cjkKaraokeAnimationEnabled,
KEY_NAVIGATION_COMPENSATION to settings.navigationCompensationEnabled,
KEY_LYRIC_BLUR_RADIUS_OFFSET to settings.lyricBlurRadiusOffsetPx.coerceIn(
ModuleSettings.MIN_LYRIC_BLUR_RADIUS_OFFSET_PX,
Expand Down Expand Up @@ -186,6 +191,7 @@ internal object ModuleSettingsSchema {
KEY_DISABLE_EDITORIAL_VIDEO_ON_TABLET,
KEY_PHONE_LIQUID_GLASS,
KEY_FUTURE_BLUR,
KEY_CJK_KARAOKE_ANIMATION_ENABLED,
KEY_NAVIGATION_COMPENSATION,
KEY_LYRIC_BLUR_RADIUS_OFFSET,
KEY_TITLE_CORRECTION_ENABLED,
Expand All @@ -212,6 +218,7 @@ internal object ModuleSettingsSchema {
"disable_editorial_video_on_tablet"
private const val KEY_PHONE_LIQUID_GLASS = "phone_liquid_glass_enabled"
private const val KEY_FUTURE_BLUR = "future_blur_enabled"
private const val KEY_CJK_KARAOKE_ANIMATION_ENABLED = "cjk_karaoke_animation_enabled"
private const val KEY_NAVIGATION_COMPENSATION = "navigation_compensation_enabled"
private const val KEY_LYRIC_BLUR_RADIUS_OFFSET = "lyric_blur_radius_offset_px"
private const val KEY_TITLE_CORRECTION_ENABLED = "title_correction_enabled"
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package dev.amenhancer.module.hook

import dev.amenhancer.module.ModuleConstants

/**
* Installs the narrow Apple Music 6.5.2 karaoke animation adaptation.
*
* The target adapter owns all version and symbol checks; unsupported hosts
* therefore report a degraded health result without touching existing lyric
* layout or blur paths.
*/
internal class CjkKaraokeAnimationFeature : FeatureHook {
override val key: String = ModuleConstants.FEATURE_CJK_KARAOKE_ANIMATION

override fun install(context: HookContext): FeatureInstallResult {
if (!context.config.settings().cjkKaraokeAnimationEnabled) {
return FeatureInstallResult.disabled("CJK 长尾歌词动画已关闭")
}
return context.target.cjkKaraokeAnimation.install().toFeatureInstallResult()
}
}
36 changes: 36 additions & 0 deletions app/src/main/java/dev/amenhancer/module/hook/CjkKaraokeWordGate.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package dev.amenhancer.module.hook

/**
* The host still owns the actual long-glow thresholds. AM++ only answers the
* safety question: is this a single, unmerged CJK native word? Returning
* false is deliberately fail-closed, so a malformed binding keeps Apple's
* original (non-overridden) classifier result.
*/
internal data class CjkKaraokeWordTiming(
val text: CharSequence,
val nativeDurationMs: Int,
val cumulativeDurationMs: Int,
val cumulativeTextLength: Int,
val splitBindingCount: Int,
val isBackground: Boolean,
)

/**
* Does not impose Apple's duration/length trigger. Those conditions remain in
* z.a0; this gate only blocks merged or multi-character CJK chunks.
*/
internal fun isSingleUnmergedCjkWord(timing: CjkKaraokeWordTiming): Boolean {
if (timing.isBackground) return false
if (timing.splitBindingCount < 0 || timing.splitBindingCount > 1) return false

val normalized = timing.text.toString().trim()
if (normalized.isEmpty() || normalized.codePointCount(0, normalized.length) != 1) {
return false
}
if (!containsCjkKaraokeScript(normalized)) return false

// g0 writes f/g as the visual chunk's accumulated duration/text length.
// Equality with the current native word's values is the non-merged case.
return timing.cumulativeDurationMs == timing.nativeDurationMs &&
timing.cumulativeTextLength == 1
}
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ private fun productionFeatureInstallationModule(
feature = FutureLyricBlurFeature(),
registerResources = { LyricCreditsRowResourceHook.install() },
),
FeatureInstallationPlan(feature = CjkKaraokeAnimationFeature()),
FeatureInstallationPlan(
feature = LyricsTypefaceFeature(),
registerResources = lyricsTypefaceSession::registerResources,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ internal data class TargetAdaptation(
val dualPane: DualPaneTarget,
val editorialVideo: EditorialVideoTarget,
val bidirectionalLyricBlur: BidirectionalLyricBlurTarget,
val cjkKaraokeAnimation: CjkKaraokeAnimationTarget = CjkKaraokeAnimationTarget {
TargetCapabilityInstall.Degraded("CJK karaoke animation target was not configured")
},
val lyricsTypeface: LyricsTypefaceTarget = LyricsTypefaceTarget {
TargetCapabilityInstall.Degraded("Lyrics typeface target was not configured")
},
Expand Down Expand Up @@ -84,6 +87,7 @@ internal data class TargetAdaptation(
dualPane = AppleMusicDualPaneTarget(resolver, build),
editorialVideo = AppleMusicEditorialVideoTarget(application, resolver),
bidirectionalLyricBlur = AppleMusicBidirectionalLyricBlurTarget(resolver),
cjkKaraokeAnimation = AppleMusicCjkKaraokeAnimationTarget(resolver),
lyricsTypeface = AppleMusicLyricsTypefaceTarget(
symbols = resolver,
session = lyricsTypefaceSession,
Expand Down Expand Up @@ -130,6 +134,10 @@ internal fun interface BidirectionalLyricBlurTarget {
fun install(): TargetCapabilityInstall
}

internal fun interface CjkKaraokeAnimationTarget {
fun install(): TargetCapabilityInstall
}

internal fun interface LyricsTypefaceTarget {
fun install(): TargetCapabilityInstall
}
Expand Down
57 changes: 57 additions & 0 deletions app/src/main/java/dev/amenhancer/module/hook/TargetSymbols.kt
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ internal enum class TargetSymbolId {
MEDIA_ENTITY_TO_SONG_CONVERTER,
STORE_FRONT_LANGUAGE_ARRAY_OWNER,
STORE_FRONT_LANGUAGE_ARRAY_METHOD,
CJK_KARAOKE_ANIMATION_OWNER,
CJK_KARAOKE_ANIMATION_METHOD,
CJK_UNICODE_BLOCK_HELPER_OWNER,
CJK_UNICODE_BLOCK_HELPER_METHOD,
}

private object AppleMusicProfiles {
Expand Down Expand Up @@ -378,12 +382,16 @@ private object AppleMusicProfiles {
TargetSymbolId.LYRICS_AVAILABILITY_OWNER to "com.apple.android.music.player.e1",
TargetSymbolId.MEDIA_ENTITY_TO_SONG_CONVERTER to "y8.B",
TargetSymbolId.STORE_FRONT_LANGUAGE_ARRAY_OWNER to "J5.a",
TargetSymbolId.CJK_KARAOKE_ANIMATION_OWNER to "com.apple.android.music.player.z",
TargetSymbolId.CJK_UNICODE_BLOCK_HELPER_OWNER to "com.apple.android.music.utils.I0\$a",
),
exactMethods = mapOf(
TargetSymbolId.PLAYER_ACTIVITY_CREATE_STACKED_NAVIGATION_HOLDER to "k1",
TargetSymbolId.PLAYER_ACTIVITY_ROOT to "n0",
TargetSymbolId.LYRICS_ITEM_UPDATE_METHOD to "o2",
TargetSymbolId.STORE_FRONT_LANGUAGE_ARRAY_METHOD to "b",
TargetSymbolId.CJK_KARAOKE_ANIMATION_METHOD to "a0",
TargetSymbolId.CJK_UNICODE_BLOCK_HELPER_METHOD to "a",
),
exactFields = mapOf(
TargetSymbolId.PLAYER_ACTIVITY_BEHAVIOR_FIELD to "c1",
Expand All @@ -402,6 +410,36 @@ private object AppleMusicProfiles {
}

internal object AppleMusicSymbols {
/**
* Apple Music 6.5.2/1586's karaoke transition entry point
* (`com.apple.android.music.player.z.a0(z$a, int, int, int, boolean)`).
* This is intentionally profile-only: a structural match on another
* obfuscated build could alter the host's animation state machine.
*/
val CjkKaraokeAnimationMethod = methodSymbol(
id = "cjk-karaoke-animation-method",
profileOwner = TargetSymbolId.CJK_KARAOKE_ANIMATION_OWNER,
profilePolicy = ProfilePolicy.EXACT_REQUIRED,
exactMethodId = TargetSymbolId.CJK_KARAOKE_ANIMATION_METHOD,
fallbackOwner = { false },
contract = ::isCjkKaraokeAnimationMethod,
)

/**
* Apple Music 6.5.2/1586's UnicodeBlock-set predicate
* (`com.apple.android.music.utils.I0$a.a(CharSequence, Set): boolean`).
* Like the animation entry point, this must never fall back to a guessed
* helper on 6.5.0/6.5.1 or an unknown host build.
*/
val CjkUnicodeBlockPredicateMethod = methodSymbol(
id = "cjk-unicode-block-predicate-method",
profileOwner = TargetSymbolId.CJK_UNICODE_BLOCK_HELPER_OWNER,
profilePolicy = ProfilePolicy.EXACT_REQUIRED,
exactMethodId = TargetSymbolId.CJK_UNICODE_BLOCK_HELPER_METHOD,
fallbackOwner = { false },
contract = ::isCjkUnicodeBlockPredicateMethod,
)

val PlayerController = classSymbol(
id = "player-controller",
profileId = TargetSymbolId.PLAYER_CONTROLLER,
Expand Down Expand Up @@ -1741,6 +1779,25 @@ internal object AppleMusicSymbols {

}

private fun isCjkKaraokeAnimationMethod(method: Method): Boolean =
!Modifier.isStatic(method.modifiers) &&
method.name == "a0" &&
method.parameterTypes.size == 5 &&
method.parameterTypes[0].name.endsWith("\$a") &&
method.parameterTypes[1] == Int::class.javaPrimitiveType &&
method.parameterTypes[2] == Int::class.javaPrimitiveType &&
method.parameterTypes[3] == Int::class.javaPrimitiveType &&
method.parameterTypes[4] == Boolean::class.javaPrimitiveType &&
method.returnType == Void.TYPE

private fun isCjkUnicodeBlockPredicateMethod(method: Method): Boolean =
Modifier.isStatic(method.modifiers) &&
method.name == "a" &&
method.parameterTypes.contentEquals(
arrayOf(CharSequence::class.java, java.util.Set::class.java),
) &&
method.returnType == Boolean::class.javaPrimitiveType

private fun classSymbol(
id: String,
profileId: TargetSymbolId? = null,
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/dev/amenhancer/module/model/ModuleModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ data class ModuleSettings(
val disableEditorialVideoOnTablet: Boolean = true,
val phoneLiquidGlassEnabled: Boolean = false,
val futureBlurEnabled: Boolean = true,
/** Enables the native rush-gradient adaptation for CJK karaoke lyrics. */
val cjkKaraokeAnimationEnabled: Boolean = true,
val navigationCompensationEnabled: Boolean = false,
val lyricBlurRadiusOffsetPx: Int = 0,
val titleCorrectionEnabled: Boolean = false,
Expand Down
12 changes: 12 additions & 0 deletions app/src/main/java/dev/amenhancer/module/ui/EmbeddedSettingsHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2237,6 +2237,18 @@ internal class EmbeddedSettingsHost private constructor(
),
) { onSettingsChanged(settings.copy(futureBlurEnabled = it)) })
addView(embeddedDivider(activity))
addView(embeddedSettingRow(
activity,
"CJK 长尾歌词动画",
"CJK 歌词启用原生 rush-gradient 动画 · 重开 Apple Music 后生效",
settings.cjkKaraokeAnimationEnabled,
iconTint = EmbeddedSettingsPalette.accent,
iconDrawable = EmbeddedGlyphDrawable(
EmbeddedGlyphKind.Music,
EmbeddedSettingsPalette.accent,
),
) { onSettingsChanged(settings.copy(cjkKaraokeAnimationEnabled = it)) })
addView(embeddedDivider(activity))
addView(embeddedSettingRow(
activity,
"歌曲名显示修正",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,15 @@ class SettingsActivity : Activity() {
store.saveSettings(store.settings().copy(futureBlurEnabled = enabled))
})
addView(insetDivider())
addView(settingRow(
title = "CJK 长尾歌词动画",
summary = "CJK 歌词启用原生 rush-gradient 动画 · 修改后重开 Apple Music",
checked = settings.cjkKaraokeAnimationEnabled,
enabled = writable,
) { enabled ->
store.saveSettings(store.settings().copy(cjkKaraokeAnimationEnabled = enabled))
})
addView(insetDivider())
addView(blurRadiusOffsetRow(
offsetPx = settings.lyricBlurRadiusOffsetPx,
enabled = writable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class ModuleSettingsSchemaTest {
disableEditorialVideoOnTablet = true,
phoneLiquidGlassEnabled = false,
futureBlurEnabled = true,
cjkKaraokeAnimationEnabled = true,
navigationCompensationEnabled = false,
lyricBlurRadiusOffsetPx = 0,
titleCorrectionEnabled = false,
Expand Down Expand Up @@ -45,6 +46,7 @@ class ModuleSettingsSchemaTest {
"disable_editorial_video_on_tablet" to false,
"phone_liquid_glass_enabled" to true,
"future_blur_enabled" to false,
"cjk_karaoke_animation_enabled" to true,
"navigation_compensation_enabled" to false,
"lyric_blur_radius_offset_px" to 6,
"title_correction_enabled" to false,
Expand Down Expand Up @@ -78,6 +80,7 @@ class ModuleSettingsSchemaTest {
"disable_editorial_video_on_tablet" to true,
"phone_liquid_glass_enabled" to true,
"future_blur_enabled" to true,
"cjk_karaoke_animation_enabled" to true,
"navigation_compensation_enabled" to false,
"lyric_blur_radius_offset_px" to 0,
"title_correction_enabled" to false,
Expand Down Expand Up @@ -141,6 +144,7 @@ class ModuleSettingsSchemaTest {
disableEditorialVideoOnTablet = false,
phoneLiquidGlassEnabled = false,
futureBlurEnabled = false,
cjkKaraokeAnimationEnabled = true,
navigationCompensationEnabled = false,
lyricBlurRadiusOffsetPx = 0,
titleCorrectionEnabled = false,
Expand Down Expand Up @@ -246,6 +250,29 @@ class ModuleSettingsSchemaTest {
)
}

@Test
fun `cjk karaoke animation defaults on and round trips`() {
assertEquals(
true,
ModuleSettingsSchema.decode(emptyMap<String, Any?>()).cjkKaraokeAnimationEnabled,
)
assertEquals(
true,
ModuleSettingsSchema.decode(
mapOf("cjk_karaoke_animation_enabled" to "not-a-boolean"),
).cjkKaraokeAnimationEnabled,
)

val encoded = ModuleSettingsSchema.encodeOrdinarySettings(
ModuleSettings(cjkKaraokeAnimationEnabled = false),
)
assertEquals(false, encoded["cjk_karaoke_animation_enabled"])
assertEquals(
false,
ModuleSettingsSchema.decode(encoded).cjkKaraokeAnimationEnabled,
)
}

@Test
fun `an old online lyric setting migrates to the custom lyrics gate`() {
val upgraded = ModuleSettingsSchema.upgrade(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ class OrdinarySettingsWritePolicyTest {
"disable_editorial_video_on_tablet" to false,
"phone_liquid_glass_enabled" to true,
"future_blur_enabled" to false,
"cjk_karaoke_animation_enabled" to true,
"navigation_compensation_enabled" to false,
"lyric_blur_radius_offset_px" to 6,
"title_correction_enabled" to false,
Expand Down
Loading
Loading