diff --git a/README.md b/README.md index a9ad3e6..77afa80 100644 --- a/README.md +++ b/README.md @@ -2,87 +2,41 @@ Android-native Apple Music-like lyric renderer for YAQMC. -> Early native implementation. The goal is to provide an Android-native replacement for the web/DOM lyric rendering path used by AppleMusic-like-lyrics-style players. It does **not** embed a WebView and it has no DOM/PixiJS dependency. +> Native Android implementation of the AppleMusic-like-lyrics rendering model. It does **not** embed a WebView and has no DOM/PixiJS dependency. The renderer itself is Jetpack Compose, while `AMLLPlayerView` lets an ordinary Android View/Capacitor host mount it without becoming a Compose-first application. ## Goals -- Native Jetpack Compose rendering -- Word-level karaoke timing -- Translation, per-word romanization and ruby annotations -- Duet/right-aligned lines -- Background-vocal grouping and slide-in behavior -- AMLL-like spring focus, line scale, opacity and distance blur transitions -- Android-like soft word-mask leading edge -- Media-time-derived word float and long-word emphasis motion -- Per-grapheme AMLL emphasis stagger, push, lift and glow -- Manual-scroll auto-align suspension and delayed spring return -- AMLL interlude detection and animated focusable dots -- Viewport-relative focus anchoring compatible with AMLL's layout model -- Android 8.0+ (`minSdk 26`), matching YAQMC's Android floor -- API shaped so YAQMC can adapt its existing parsed lyric data directly - -## Current replication status - -The first thirty-one AMLL-parity passes now cover the main timing, annotation, interaction, focus-motion, measured layout, background-vocal geometry, line-transform, group-opacity, mask, responsive-wrapper, playback-control, end-of-song, emphasized-shadow and annotation-mask behavior instead of relying on generic Compose defaults: - -- active/inactive main-line scale follows AMLL's `1.0 / 0.97` behavior using the upstream physical scale spring -- `enableScale` mirrors upstream `setEnableScale()` and disables only the main-line 97% treatment; background-vocal 75% inactive scale remains independent -- `enableSpring` mirrors upstream `setEnableSpring()`; disabling physical springs falls back to AMLL's 500ms CSS `ease` transform transition rather than snapping transforms -- background lyrics stay grouped with their primary line rather than becoming independent scroll targets -- background-vocal size/opacity, first-word ordering and always-postposition behavior follow AMLL's grouped presentation model -- background-vocal wrapper motion is driven by the same dynamic vertical spring policy as AMLL's `bgSlideY`, including `+80 / -80 -> 0`, measured-height translation and `0.8 -> 1.0` wrapper scale -- background-first vocals unfold their occupied measured height with the slide transform; post-positioned vocals keep AMLL's in-flow/out-of-flow visibility semantics -- background lyric lines have their own independent `1.0 / 0.75` transform instead of inheriting the main line's `0.97` scale -- word highlighting uses AMLL's continuous word-level bright-to-dark gradient with the Android-like `1em` fade-width default -- gradient geometry uses measured word height for fade width and keeps AMLL's half-fade lead-in/tail around each timed word -- AMLL SOLID/GRADIENT mask endpoints use the upstream alpha targets (`0.2`, `1.0`, `0.4`) and mode-specific `450ms / 300ms ease-out` transitions -- dynamic group opacity follows upstream targets: highlighted groups use `0.85`, ordinary dynamic rows stay at `1.0`, with the DOM-style `0.4s ease` transition instead of distance fading -- `hidePassedLines` follows AMLL's playing-only passed-boundary behavior and uses the same near-zero `1e-4` target; paused lyrics are restored -- every word keeps AMLL's regular playback-time-derived upward float, including emphasized words -- long-word emphasis uses AMLL's eligibility, duration mapping, two-half easing, final-word amplification and grapheme stagger -- emphasized graphemes reproduce AMLL's horizontal push, vertical lift, scale and duration-derived glow envelope -- emphasized grapheme glow uses a zero-offset native text shadow with the upstream white color, direct `emphasis * blur` alpha and direct `min(0.3, blur * 0.3)em` blur radius; the renderer no longer adds halo scaling or extra alpha/radius attenuation -- the shadow is rendered per grapheme on Android 8+ without depending on API-31-only `RenderEffect`, while the existing karaoke bright/dark mask path remains unchanged -- ruby annotations and per-word romanization participate in merged word layout and ruby character count drives AMLL emphasis stagger anchors -- ruby, base glyphs and per-word romanization now sample one shared word-level bright-to-dark mask, matching upstream's `mainWordEl` mask ownership instead of using independent annotation progress clips -- ruby segment timing drives the whole measured word-mask sweep using UTF-16 code-unit counts like JavaScript `string.length`; the sweep pauses across ruby timing gaps and clamps segment timing to the word bounds -- annotation-aware mask geometry uses the full visual word-box width and stacked ruby + base + roman content height, with no native-only inter-band gap; roman text also preserves upstream's `0.3em` inline-end padding offset -- annotation layout reserves explicit above-baseline space so ruby/romanized text does not distort main-word timing geometry -- word/character motion is reconstructed directly from media time, so seeking is deterministic rather than free-running -- ordinary playback-clock `update(...)` samples are distinct from explicit host `seekTo(...)` calls, so explicit seeks always use seek-motion semantics -- automatic seek inference can be enabled/disabled on `AMLLPlayerState`; changing the flag resets the detector baseline while explicit seeks remain authoritative -- playback seek inference ports AMLL's monotonic wall-clock detector, including jitter/drift tolerance and repeated equal-position samples while automatic detection is enabled -- distance-based lyric blur is derived from focus distance; supported Android versions use native blur effects -- lyric gaps become focusable AMLL-style interlude dot items with timed entrance/breathing/brightening/exit motion -- touch scrolling only suspends auto-follow after AMLL's `> 10px` intent threshold; wheel input uses the upstream-style idle debounce -- native Android drag/fling physics remain owned by `LazyColumn`; the five-second auto-align delay starts only after physical scrolling is actually idle -- repeated touch input, tap interruptions and multi-touch re-anchoring restart the same suspension lifecycle without consuming native pointer events -- focus scrolling uses AMLL's interval-adaptive vertical spring policy; seek/interlude motion switches to the slower upstream spring, or the shared 500ms transform fallback when springs are disabled -- end-of-song is derived from the maximum main-line `endTime` across all lyric groups, matching upstream without requiring media duration/end callbacks from the host -- end-of-song clears active lyric highlighting and switches focus motion to AMLL's medium end spring -- an optional measured `bottomLine` Compose slot stays at the real end of the lyric list; at end-of-song it becomes the focus target, otherwise the final lyric group is focused -- the bottom line follows AMLL's `0.20 -> 0.85` focus opacity hierarchy, approximately `0.7em` typography, inherited lyric weight and distance-blur behavior -- automatic focus defaults to AMLL's viewport-relative `Center @ 0.35` alignment instead of a fixed dp offset -- Top / Center / Bottom focus anchors are supported and use the target item's measured height like upstream layout -- leading/trailing list space expands from the real composed viewport size so the first, last and optional bottom-line items can reach the configured focus anchor -- lyric-group vertical rhythm comes from AMLL's measured `0.4em` wrapper padding and `0.3em` main/background gap rather than a fixed global dp gap -- horizontal wrapper padding is responsive like upstream: `20dp` at <=500dp composed width, otherwise `1em`; hosts may explicitly override it -- secondary lyric defaults follow AMLL's `0.5em` font, `0.75em` total line-height and 0.3 opacity hierarchy -- songs containing duet lines measure each speaker at 85% content width on the correct side, so wrapping and group height follow AMLL's 15% opposite-speaker inset -- lyric typography uses the react-full default weight 600 consistently across main, secondary and background content; active-state changes no longer alter glyph metrics or wrapping -- CI builds the library/demo and runs native grouping, word-motion, text-shadow, annotation, interlude, interaction, spring, seek, focus-geometry, end-of-song, line-layout, background-motion, line-scale, opacity, continuous-mask, responsive-padding, behavior-flag, transform-policy and typography unit tests - -The renderer is still evolving. Remaining fidelity work includes browser-CSS versus Android/Skia shadow-kernel rasterization differences, residual DOM-versus-Compose/Skia font-metric and subpixel annotation-layout differences, additional upstream configuration parity where useful, and platform-specific performance tuning. - -## Non-goals for the first milestone - -- Reimplement every parser from upstream AMLL -- Couple the library to QQ Music or YAQMC playback internals -- Use WebView, DOM, CSS or PixiJS - -## Usage - -完整中文接入文档见 [`docs/USAGE.zh-CN.md`](docs/USAGE.zh-CN.md),包括 Gradle 引入、YAQMC DTO adapter、播放状态同步、背景人声/对唱、曲末底栏、样式配置和完整 Compose 示例。 +- Android 8.0+ (`minSdk 26`) +- word-level karaoke timing and continuous AMLL-style masks +- translation, per-word romanization and timed ruby annotations +- duet/right-aligned lyrics and grouped background vocals +- AMLL-style focus springs, scale, opacity, blur, interludes and manual-scroll behavior +- deterministic media-time-derived animation across pause/seek/resume +- stable host APIs for both Compose and traditional Android View applications +- packaging suitable for direct YAQMC consumption + +## Current status + +The first thirty-six implementation/integration passes now cover the major AMLL rendering and Android-host boundaries: + +- main-line `1.0 / 0.97` scale, independent background `1.0 / 0.75` scale, `enableScale`, `enableSpring` and the 500ms non-spring fallback +- grouped background vocals, first-word ordering, forced postposition, measured expansion and duet-side geometry +- balanced lyric wrapping, AMLL word chunking, measured wrapper rhythm, responsive `20dp / 1em` horizontal padding and stable weight-600 typography +- continuous bright-to-dark word masks with upstream SOLID/GRADIENT alpha targets and transition timings +- per-word float, long-word emphasis, per-grapheme push/lift/scale and native zero-offset text shadow on Android 8+ +- ruby and per-word romanization participating in measured word boxes and sharing one parent word-level mask with the base glyphs +- ruby-segment flex geometry, UTF-16 timing sweep and annotation-aware mask height/width +- emphasized descendants moving underneath a mask that remains in parent-word space, matching upstream DOM mask ownership +- touch/wheel manual-scroll suspension, delayed auto-align, viewport-relative focus anchors, adaptive focus springs and explicit/automatic seek semantics +- interlude dots, end-of-song focus and optional measured `bottomLine` +- `enableBlur`, `hidePassedLines`, `alwaysPostpositionBackground` and other commonly used upstream behavior flags +- upstream-style obscene-word preprocessing through `Disabled`, `FullMask` and `PartialMask` modes while preserving timing/ruby/roman metadata +- `AMLLPlayerView` for BridgeActivity/Fragment/Capacitor/legacy View hosts; the published AAR is smoke-tested from a consumer that does not apply the Compose compiler plugin +- Maven publication tested through isolated consumers, plus credential-gated GitHub Packages publication for cross-repository delivery + +Remaining fidelity work is mostly narrower platform differences: browser CSS versus Android/Skia rasterization, residual DOM-versus-Skia font/shaping/subpixel differences, less frequently used upstream configuration, and real-device performance tuning with large YAQMC lyric datasets. + +## Compose usage ```kotlin val state = remember { AMLLPlayerState() } @@ -111,7 +65,36 @@ AMLLPlayer( ) ``` -The renderer derives end-of-song from lyric-group timing, so the host does not need to provide a separate media duration/end signal for bottom-line focus. +## Traditional Android View / Capacitor usage + +A host does not need to apply the Compose compiler plugin just to mount the native renderer: + +```kotlin +val lyricsView = AMLLPlayerView(context).apply { + setLyricLines(lines) + update(positionMs = currentPositionMs, isPlaying = isPlaying) + onLineClick = { line -> player.seekTo(line.startTimeMs) } +} + +container.addView( + lyricsView, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), +) +``` + +`AMLLPlayerView` uses `DisposeOnViewTreeLifecycleDestroyed`; mount it under an Activity/Fragment View tree with a normal lifecycle owner. Mutating calls follow ordinary Android View semantics and should be made on the main thread. + +## Obscene-word masking + +```kotlin +state.updateMaskObsceneWordsMode(MaskObsceneWordsMode.PartialMask) +state.updateMaskObsceneWordChar('*') +``` + +`LyricWord.obscene` is processed from the original unmasked lyric data, so changing mode or mask character is reversible without re-fetching lyrics. Ruby/roman annotations and timing metadata are preserved. ## Model @@ -142,21 +125,61 @@ data class LyricLine( ) ``` -## YAQMC integration plan +## Dependency and publication -1. Keep YAQMC's existing lyric fetch/parse pipeline. -2. Add a tiny adapter from YAQMC's lyric DTO to `LyricLine`. -3. Mount this Compose UI in the Android host rather than rendering lyrics in Capacitor/WebView. -4. Feed the native renderer from the same playback position source used by Android MediaSession/native audio. -5. Once stable, optionally move the whole Android full-screen lyric page to native Compose. +Local development: + +```bash +./gradlew :amll:publishReleasePublicationToMavenLocal +``` + +```kotlin +repositories { + mavenLocal() + google() + mavenCentral() +} + +dependencies { + implementation("dev.yaqmc:amll-android:0.1.0-SNAPSHOT") +} +``` + +The repository also contains a GitHub Packages publish workflow. A release tag such as `v0.1.0-alpha.1` is published as Maven version `0.1.0-alpha.1`; manual workflow dispatch can provide a version directly. Publishing uses the repository `GITHUB_TOKEN` with `packages: write`. + +For a different private repository such as `YAQMC/YAQMC`, package download credentials are still required. CI may use its `GITHUB_TOKEN` only when it has read access to the package; otherwise use a PAT classic with `read:packages`. Do not commit package credentials to the repository. + +## YAQMC integration direction + +1. Keep YAQMC's existing lyric fetch/parse/provider pipeline. +2. Convert the existing lyric document into `LyricLine` only when lyrics change. +3. Mount `AMLLPlayerView` as a native sibling/overlay of the existing Capacitor WebView. +4. Drive playback position/play state from YAQMC's Android/Rust Core clock instead of pushing per-frame updates through JavaScript. +5. Route lyric-line clicks back to the native seek command. +6. Once the overlay path is stable, decide whether the whole Android full-screen lyric page should move native. ## Build baseline -- compile/target SDK 37 -- AGP 9.4.0 -- Kotlin/Compose compiler plugin 2.3.21 -- Compose BOM 2026.08.00 +- compile SDK 36 +- demo target SDK 36 - minSdk 26 +- AGP 8.13.0 +- Kotlin / Compose compiler plugin 2.2.20 +- Compose BOM 2026.06.00 (Compose 1.11 generation) +- Gradle 8.14.3 +- Java / Kotlin JVM target 21 + +CI builds/tests the library and demo, publishes the release artifact to Maven Local, then compiles both a Compose consumer and a plain Android View consumer against the published coordinate. + +## Documentation + +完整中文接入说明见 [`docs/USAGE.zh-CN.md`](docs/USAGE.zh-CN.md)。 + +## Non-goals for the first milestone + +- reimplement every upstream parser +- couple `amll-android` to QQ Music or YAQMC network/provider internals +- introduce a WebView/DOM/PixiJS rendering dependency ## License diff --git a/docs/USAGE.zh-CN.md b/docs/USAGE.zh-CN.md index 3c153db..8f65db4 100644 --- a/docs/USAGE.zh-CN.md +++ b/docs/USAGE.zh-CN.md @@ -1,22 +1,33 @@ # AMLL Android 调用与接入指南 -本文面向 YAQMC Android 端以及其他 Jetpack Compose 宿主,说明如何把播放状态和现有歌词 DTO 接入 `amll-android`。 +本文面向 YAQMC Android 以及其它 Android 宿主,说明如何接入 `amll-android`。 -> 当前库是纯 Android/Compose 实现,不使用 WebView、DOM 或 PixiJS。最低 Android 版本为 API 26。 +> 渲染器本身使用 Jetpack Compose,但不依赖 WebView、DOM 或 PixiJS。Compose 宿主可以直接使用 `AMLLPlayer`;传统 Activity / Fragment / Capacitor / View 宿主可以使用 `AMLLPlayerView`,无需为了歌词组件把整个宿主改成 Compose。 -## 1. 引入模块 +最低 Android 版本为 API 26。 -### 方式 A:同一 Gradle 工程直接引用 +## 1. 当前构建基线 -如果 YAQMC 与本仓库源码放在同一个 Gradle 工程中: +- compile SDK 36 +- demo target SDK 36 +- minSdk 26 +- AGP 8.13.0 +- Kotlin / Compose compiler plugin 2.2.20 +- Compose BOM 2026.06.00(Compose 1.11 generation) +- Gradle 8.14.3 +- Java / Kotlin JVM target 21 + +这套基线与当前 YAQMC Android 宿主对齐,避免独立 AAR 在 Kotlin metadata、AGP 或 compileSdk 上高于宿主。 + +## 2. 引入依赖 + +### 2.1 同一 Gradle 工程 ```kotlin // settings.gradle.kts include(":amll") ``` -应用模块: - ```kotlin // app/build.gradle.kts dependencies { @@ -24,15 +35,15 @@ dependencies { } ``` -### 方式 B:本地 Maven +### 2.2 Maven Local -仓库当前已经配置 Maven publication,但这里不假设远程仓库已经发布。开发阶段可先发布到本机: +先在 `amll-android` 仓库发布: ```bash -./gradlew :amll:publishToMavenLocal +./gradlew :amll:publishReleasePublicationToMavenLocal ``` -宿主工程: +宿主: ```kotlin repositories { @@ -46,92 +57,64 @@ dependencies { } ``` -## 2. 最小可运行示例 +CI 会把 release AAR 发布到 Maven Local,然后使用两个**独立 Gradle consumer**重新编译:一个验证 Compose API,一个验证不启用 Compose compiler plugin 的普通 Android View 宿主。 -核心只有三步:构造歌词、维护 `AMLLPlayerState`、渲染 `AMLLPlayer`。 +### 2.3 GitHub Packages -```kotlin -@Composable -fun LyricsScreen( - lines: List, - playbackPositionMs: Long, - isPlaying: Boolean, - seekTo: (Long) -> Unit, -) { - val state = remember { AMLLPlayerState() } +仓库已经支持 repository-scoped GitHub Packages publication。发布 workflow 可以由 GitHub Release 或手动 dispatch 触发。 - LaunchedEffect(lines) { - state.setLyricLines(lines) - } +发布版本来自: - LaunchedEffect(playbackPositionMs, isPlaying) { - state.update( - positionMs = playbackPositionMs, - isPlaying = isPlaying, - ) - } +1. 环境变量 `AMLL_VERSION`; +2. Gradle property `-PamllVersion=...`; +3. 都不存在时回退 `0.1.0-SNAPSHOT`。 - AMLLPlayer( - state = state, - modifier = Modifier.fillMaxSize(), - onLineClick = { line -> - seekTo(line.startTimeMs) - // 让 UI 立即响应,下一次宿主播放器 position 回调会继续校准。 - state.seekTo(line.startTimeMs) - }, - ) -} -``` +例如 tag `v0.1.0-alpha.1` 会发布成 Maven version `0.1.0-alpha.1`。 -如果播放时钟本身就是 Flow/回调流,推荐直接在收集位置更新状态,而不是额外做 16 ms 的 UI 定时器: +Gradle 发布仓库只会在 `GITHUB_ACTOR` 和 `GITHUB_TOKEN` 都存在时注册,因此普通本地/PR 构建不需要 GitHub 凭据。 -```kotlin -LaunchedEffect(player) { - player.playbackState.collect { snapshot -> - state.update( - positionMs = snapshot.positionMs, - isPlaying = snapshot.isPlaying, - ) - } -} -``` +跨私有仓库消费时仍需认证。对于 `YAQMC/YAQMC`: -播放器能多频繁提供位置就多频繁推送。默认开启的 AMLL auto-seek 推导会观察每次 position sample,包括数值相同的重复 sample;明确的宿主 seek 则应单独使用 `state.seekTo(...)`。 +- 如果它的 Actions token 被授予该 package 的读取权限,可以使用仓库 `GITHUB_TOKEN`; +- 否则需要 PAT classic,并包含 `read:packages`; +- 不要把 token 写进 `settings.gradle.kts` 或提交到仓库。 -## 3. 歌词数据模型 +## 3. 歌词模型 -### `LyricWord` +### `LyricRuby` ```kotlin -LyricWord( +LyricRuby( startTimeMs = 12_000, endTimeMs = 12_450, - text = "你", - romanText = "ni", // 可选:逐词音译,显示在词下方 - obscene = false, - ruby = emptyList(), // 可选:逐段 ruby,显示在词上方 + text = "ㄋㄧˇ", ) ``` -字段含义: - -- `startTimeMs` / `endTimeMs`:逐词时间,毫秒。 -- `text`:词本体;空格、标点可直接保留在文本中。 -- `romanText`:这个词自己的 romanization,不同于整行 `romanLyric`。 -- `ruby`:带独立时间的上标注音段。 -- `obscene`:预留给歌词优化/屏蔽逻辑;当前渲染接口允许模型携带该信息。 +一个 `LyricWord` 可以包含多个 ruby segment。renderer 会保持 segment 独立 shaping/flex geometry;存在 ruby 时,segment 时间会驱动整个 visual word box 的 mask 扫描。 -### `LyricRuby` +### `LyricWord` ```kotlin -LyricRuby( +LyricWord( startTimeMs = 12_000, endTimeMs = 12_450, - text = "ㄋㄧˇ", + text = "你", + romanText = "ni", + obscene = false, + ruby = emptyList(), ) ``` -同一个词可以有多个 ruby segment;每段有自己的高亮时间。当前 renderer 与 upstream 一样,让 ruby、主字和逐词 `romanText` 共用同一个 word-level bright→dark mask;存在 ruby segment 时,segment 的时间会驱动整个词的 mask 扫描,并在 segment 间的时间空档保持当前位置。 +字段: + +- `startTimeMs` / `endTimeMs`:逐词时间; +- `text`:基础歌词文本; +- `romanText`:逐词 romanization,显示在主字下方; +- `ruby`:逐段带时间的上方注音; +- `obscene`:标记为需要按上游规则进行屏蔽的词。 + +Ruby、主字、逐词 romanization 共用一个 parent word-level bright→dark mask。强调动画发生在子 grapheme 上时,mask 仍停留在 parent word coordinate space,不会跟着字符 transform 一起移动。 ### `LyricLine` @@ -147,56 +130,93 @@ LyricLine( ) ``` -如果不显式传 `startTimeMs/endTimeMs`,会从 `words` 的最早开始时间和最晚结束时间推导。 - -`translatedLyric` 和 `romanLyric` 是**整行副歌词**;`LyricWord.romanText` 是跟随单词排版和运动的**逐词音译**,二者可以同时存在。 - -## 4. 背景人声与对唱 +`translatedLyric` / `romanLyric` 是整行副歌词;`LyricWord.romanText` 是逐词 annotation,两者可以同时存在。 -### 背景人声 - -背景人声使用单独一条 `LyricLine`: +## 4. Compose 宿主 ```kotlin -val main = LyricLine( - words = mainWords, - isBackground = false, -) +@Composable +fun LyricsScreen( + lines: List, + playbackPositionMs: Long, + isPlaying: Boolean, + seekTo: (Long) -> Unit, +) { + val state = remember { AMLLPlayerState() } -val background = LyricLine( - words = backgroundWords, - isBackground = true, -) + LaunchedEffect(lines) { + state.setLyricLines(lines) + } -val lines = listOf(main, background) -``` + LaunchedEffect(playbackPositionMs, isPlaying) { + state.update( + positionMs = playbackPositionMs, + isPlaying = isPlaying, + ) + } -**重要:背景行在输入顺序上必须紧跟对应主行。** 当前 renderer 会把一条 `isBackground = true` 的行绑定到前一个还没有背景行的主行,并把二者作为同一个滚动目标。 + AMLLPlayer( + state = state, + modifier = Modifier.fillMaxSize(), + style = AMLLStyle(), + onLineClick = { line -> + seekTo(line.startTimeMs) + state.seekTo(line.startTimeMs) + }, + bottomLine = { + Text("Lyrics by Example Artist") + }, + ) +} +``` -AMLL 判断背景人声显示在主行上方还是下方时比较的是两行的**第一个真实 word 时间**。因此如果背景人声实际上先唱,可以保留更早的 `background.words.first().startTimeMs`;为了保证 group 顺序,建议 parser/adapter 仍然输出 `main -> background` 的行顺序。对于原始数据中 line start 被同步过的情况尤其适合这种方式。 +## 5. 传统 Android View / Capacitor 宿主 -如需强制背景人声永远后置: +`AMLLPlayerView` 是 `AbstractComposeView` wrapper。宿主代码本身不需要出现 `@Composable`,也不需要启用 Compose compiler plugin。 ```kotlin -AMLLStyle( - alwaysPostpositionBackground = true, +val lyricsView = AMLLPlayerView(context).apply { + setLyricLines(lines) + update( + positionMs = currentPositionMs, + isPlaying = isPlaying, + ) + onLineClick = { line -> + player.seekTo(line.startTimeMs) + seekTo(line.startTimeMs) + } +} + +container.addView( + lyricsView, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ), ) ``` -### 对唱 +可直接调用: ```kotlin -LyricLine( - words = words, - isDuet = true, -) +lyricsView.setLyricLines(lines) +lyricsView.update(positionMs, isPlaying) +lyricsView.updatePosition(positionMs) +lyricsView.updatePlaybackState(isPlaying) +lyricsView.seekTo(positionMs) +lyricsView.setAutoSeekDetectionEnabled(enabled) +lyricsView.setMaskObsceneWordsMode(mode) +lyricsView.setMaskObsceneWordChar('*') +lyricsView.style = AMLLStyle(...) ``` -`isDuet = true` 会使用右侧 speaker 布局;歌曲只要包含 duet 行,renderer 会为两侧 speaker 保留对应的可用宽度。 +也可以通过 `lyricsView.state` 获取底层 `AMLLPlayerState` 做高级配置。 -## 5. 播放状态同步 +`AMLLPlayerView` 使用 `ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed`,应挂在正常 Activity/Fragment lifecycle View tree 下。和普通 View 修改一样,状态更新建议在 Android main thread 调用。 -推荐使用: +## 6. 播放状态与 Seek + +推荐入口: ```kotlin state.update( @@ -205,67 +225,87 @@ state.update( ) ``` -也可以分别调用: +明确 seek 时: ```kotlin -state.updatePlaybackState(isPlaying) -state.update(positionMs) +player.seekTo(targetMs) +state.seekTo(targetMs) ``` -或在明确 seek 时: +不要只停止 position 更新而继续让 `isPlaying = true`。暂停状态会改变 inactive line scale、背景人声展开等视觉语义。 + +### 自动 Seek 推导 + +默认开启: ```kotlin -state.seekTo(newPositionMs) +state.updateAutoSeekDetectionEnabled(true) ``` -### 暂停 +关闭后,普通 position sample 不再进入自动 drift detector;明确调用 `seekTo()` 仍始终使用 seek-motion 语义。 -必须同步 `isPlaying = false`。AMLL 的暂停视觉语义不只是停止时间:暂停时 inactive 主行会回到 100% scale,背景人声也会保持展开,因此不要只停止 position 更新而仍让 `isPlaying` 保持 `true`。 +renderer 的运动由 media time 重建,不维护一个与播放器脱离的“自由运行歌词时钟”。因此 seek/pause/resume 后可以确定性恢复视觉状态。 -### Seek +## 7. Obscene word masking -实际 seek 建议同时做两件事: +v33 起 `LyricWord.obscene` 会被实际处理,而不是预留字段。 ```kotlin -player.seekTo(targetMs) -state.seekTo(targetMs) +state.updateMaskObsceneWordsMode(MaskObsceneWordsMode.Disabled) +state.updateMaskObsceneWordsMode(MaskObsceneWordsMode.FullMask) +state.updateMaskObsceneWordsMode(MaskObsceneWordsMode.PartialMask) +state.updateMaskObsceneWordChar('*') ``` -第一步修改真实播放器,第二步让歌词 UI 立刻跳到目标时间并明确标记为 host seek。之后继续用真实播放器的位置回调驱动 `state.update(...)`。 +语义与 upstream 对齐: -普通 `state.update(...)` 和明确的 `state.seekTo(...)` 在内部是两条不同语义:前者只是播放时钟 sample,后者即使在自动 seek 检测关闭时也始终会触发 seek-motion 语义。 +- `Disabled`:保留原文本; +- `FullMask`:所有非空白字符替换为 mask char; +- `PartialMask`:短词全屏蔽;较长词保留首尾可见字符,中间非空白字符屏蔽。 -### 自动 Seek 推导 +`AMLLPlayerState` 内部保留原始 lyric lines。运行时切换 mode/char 会从**原始文本**重新处理,因此不会发生“已经打星后再继续打星”的不可逆问题。 -默认与 AMLL 一样开启: +屏蔽只改基础 lyric text;word timing、ruby、romanization 等元数据保持不变。 + +## 8. 背景人声、对唱与注音 + +### 背景人声 + +背景行必须紧跟对应主行: ```kotlin -val state = remember { - AMLLPlayerState(initialAutoSeekDetectionEnabled = true) -} +val lines = listOf( + LyricLine(words = mainWords), + LyricLine(words = backgroundWords, isBackground = true), +) ``` -如果宿主播放时钟粒度太粗,频繁被自动 drift detector 识别成 seek,可以关闭自动推导: +renderer 会把它们组合成一个滚动目标。背景先唱/后唱的布局判断依据两行第一个真实 word 的时间,而不是简单依赖同步过的 line start。 + +强制背景人声后置: ```kotlin -state.updateAutoSeekDetectionEnabled(false) +AMLLStyle(alwaysPostpositionBackground = true) ``` -再次开启: +### 对唱 ```kotlin -state.updateAutoSeekDetectionEnabled(true) +LyricLine( + words = words, + isDuet = true, +) ``` -切换开关会重置 detector 基线。关闭时 renderer 不再调用自动 detector,但显式 `state.seekTo(...)` 仍然始终有效;这与当前 upstream `setCurrentTime()` 的运行时代码一致。 +存在 duet 时,两侧 speaker 会按 AMLL 几何使用约 85% 可用内容宽度并保留 opposite-side inset。 -### 曲末与 Bottom Line +### Ruby / Roman -曲末状态不需要宿主额外提供媒体 duration 或 ended 回调。renderer 与当前 upstream AMLL 一样,会扫描所有歌词 group 的**主歌词 `endTimeMs`**,取最大值作为歌词时间线终点;当 `positionMs >= maxEndTimeMs` 时进入 end-of-song 状态。 +Ruby segment 独立 shape,然后以 flex-row-like 方式居中。Mask sweep 使用 ruby segment UTF-16 code-unit count,保留 segment timing gap;逐词 romanization 保留 upstream `0.3em` inline-end padding 语义。 -这里特意不是直接读取“最后开始的那一行”的结束时间,因为尾部歌词可能重叠;也不会让背景人声自己的 `endTimeMs` 独立延长 group 生命周期,这与 upstream `LyricLineGroupBase.endTime` 一致。 +## 9. 曲末与 Bottom Line -可以通过 `bottomLine` slot 在歌词末尾放歌曲创作者、来源等信息: +renderer 从所有主歌词 group 的最大 `endTimeMs` 推导 end-of-song,不要求宿主再提供 media duration/ended signal。 ```kotlin AMLLPlayer( @@ -276,166 +316,69 @@ AMLLPlayer( ) ``` -`bottomLine` 是真实的 trailing `LazyColumn` item,会使用实际测量高度参与 `Top / Center / Bottom` focus anchor。到达曲末时: - -- 传了 `bottomLine`:自动聚焦 bottom line; -- 没传 `bottomLine`:自动聚焦显示顺序中的最后一个主歌词 group; -- 所有歌词行退出 active/highlighted 视觉; -- focus motion 切换到 AMLL 的曲末 medium spring; -- bottom line 从约 `0.20` opacity 过渡到 `0.85`,并参与距离 blur。 - -因此 YAQMC 只需要继续提供真实播放 position,不需要为了这个行为再额外同步歌曲 duration。 - -## 6. YAQMC DTO 适配 +到达歌词时间线末尾后,active lyric 状态清空;有 `bottomLine` 时自动聚焦它,否则聚焦最后一个 lyric group。 -库刻意不依赖 YAQMC、QQ Music 或某一种歌词 parser。推荐在 YAQMC Android 层放一个很薄的 adapter。 - -下面字段名是示例,请替换成 YAQMC 当前 DTO 的真实字段: +## 10. 常用样式 ```kotlin -fun YaqmcLineDto.toAMLL(): LyricLine { - val mappedWords = words.map { word -> - LyricWord( - startTimeMs = word.startMs, - endTimeMs = word.endMs, - text = word.text, - romanText = word.romanization, - ruby = word.rubySegments.map { ruby -> - LyricRuby( - startTimeMs = ruby.startMs, - endTimeMs = ruby.endMs, - text = ruby.text, - ) - }, - ) - } - - return LyricLine( - words = mappedWords, - translatedLyric = translation.orEmpty(), - romanLyric = romanization.orEmpty(), - startTimeMs = startMs, - endTimeMs = endMs, - isBackground = isBackground, - isDuet = isDuet, - ) -} - -fun List.toAMLLLyrics(): List = - map(YaqmcLineDto::toAMLL) -``` - -建议 adapter 只负责数据转换,不把播放器实例、MediaSession、网络请求或 parser 逻辑放进 `amll` module。 - -## 7. 样式配置 - -默认样式已经尽量对齐 AMLL,可以只覆盖宿主真正需要改变的项: - -```kotlin -val lyricStyle = AMLLStyle( +AMLLStyle( lineFontSize = 34.sp, secondaryFontSize = 17.sp, - activeColor = Color.White, lyricFontWeight = FontWeight.SemiBold, enableBlur = true, enableScale = true, enableSpring = true, hidePassedLines = false, + alwaysPostpositionBackground = false, wordFadeWidthEm = 1f, alignPosition = 0.35f, alignAnchor = AMLLAlignAnchor.Center, ) - -AMLLPlayer( - state = state, - style = lyricStyle, -) ``` -常用项: - -| 参数 | 含义 | 默认语义 | -| --- | --- | --- | -| `lineFontSize` | 主歌词字号 | `34.sp` | -| `secondaryFontSize` | 翻译/整行音译字号 | `17.sp`,即主字号约 0.5em | -| `lyricFontWeight` | 歌词统一字重 | `600 / SemiBold` | -| `activeColor` | 已唱/亮色 | 白色 | -| `inactiveColor` | active gradient 的暗端基色 | AMLL gradient dark 约 0.4 alpha | -| `backgroundLineScale` | 背景歌词字号比例 | `0.70` | -| `alwaysPostpositionBackground` | 背景人声是否强制后置 | `false` | -| `enableBlur` | 距离模糊 | `true` | -| `enableScale` | 是否启用主歌词 inactive 97% 缩放 | `true`;不影响背景歌词独立的 75% scale | -| `enableSpring` | 是否启用物理弹簧 transform | `true`;关闭后按 upstream 回退为 500ms CSS `ease` transform transition | -| `hidePassedLines` | 播放时隐藏已经越过焦点边界的歌词 | `false`;暂停时旧行会恢复 | -| `wordFadeWidthEm` | 连续逐词 bright→dark gradient 的过渡宽度 | `1em`,Android-like | -| `horizontalPadding` | 歌词 wrapper 左右留白 | 默认响应式:容器宽度 `<=500dp` 为 `20dp`,否则 `1em`;显式 Dp 为固定覆盖 | -| `alignPosition` | 焦点位于 viewport 高度的比例 | `0.35` | -| `alignAnchor` | 焦点对齐目标行的 Top/Center/Bottom | `Center` | -| `autoAlignResumeDelayMs` | 手动滚动结束后恢复跟随的延迟 | `5000 ms` | - -`verticalPadding`、`lineSpacing` 等也可以覆盖,但如果目标是 AMLL parity,优先保留默认值。 - -## 8. 完整 Compose 接入模板 +重点默认行为: -```kotlin -@Composable -fun NativeFullScreenLyrics( - lyricLines: List, - player: YaqmcPlayer, -) { - val state = remember { AMLLPlayerState(initialIsPlaying = player.isPlaying) } +- 主歌词 inactive scale `0.97`; +- 背景歌词 inactive scale `0.75`; +- `enableSpring = false` 时使用约 500ms CSS-ease 风格 transform fallback,不瞬移; +- `hidePassedLines = false`; +- focus 默认 `Center @ 0.35`; +- `horizontalPadding = Dp.Unspecified` 时,容器 `<=500dp` 用 `20dp`,更宽时用约 `1em`; +- 字重默认 `600 / SemiBold`; +- word fade width 默认 `1em`。 - LaunchedEffect(lyricLines) { - state.setLyricLines(lyricLines) - } +## 11. YAQMC 推荐接入结构 - LaunchedEffect(player) { - player.positionFlow.collect { position -> - state.update( - positionMs = position, - isPlaying = player.isPlaying, - ) - } - } +当前 YAQMC Android 是 Capacitor `BridgeActivity + WebView`,因此推荐先采用 native overlay,而不是一次重写整个页面: - AMLLPlayer( - state = state, - modifier = Modifier.fillMaxSize(), - style = AMLLStyle(), - onLineClick = { line -> - player.seekTo(line.startTimeMs) - state.seekTo(line.startTimeMs) - }, - bottomLine = { - Text("Lyrics by ${player.currentTrack.lyricArtist}") - }, - ) -} -``` +1. 保留现有歌词 provider / fetch / parser; +2. 歌词变化时,把当前 `LyricDocument` 转成 `List`; +3. 在 Android `MainActivity` 的 View hierarchy 中把 `AMLLPlayerView` 挂成 WebView sibling/overlay; +4. JavaScript 只负责页面状态和“显示/隐藏 native lyrics”等低频事件,不做逐帧 position bridge; +5. position/play/pause/seek 优先直接读取 YAQMC Android/Rust Core 的 native playback clock; +6. `onLineClick` 直接调用 native seek,再同步 `AMLLPlayerView.seekTo()`; +7. overlay 路径稳定后,再决定是否把 Android full-screen lyrics 整页迁移到 native UI。 -`YaqmcPlayer`、`positionFlow`、`currentTrack.lyricArtist` 只是宿主接口示意;核心要求只有:**把真实 media position 和 isPlaying 持续喂给 `AMLLPlayerState`。** 曲末焦点由歌词时间线自己推导,不要求宿主再提供 duration。 +这样既能保留现有 YAQMC Web 前端,又能把歌词这种高频、动画密集的渲染链路移出 WebView。 -## 9. 生命周期与性能建议 +## 12. 生命周期与性能 -- 一个歌词页面保持一个 `AMLLPlayerState`,不要每个 position tick 重建 state。 -- 换歌时调用 `setLyricLines(newLines)`,并立即 `seekTo()` 或等待真实 position sample 校准。 -- 不需要为了 renderer 自己再启动高频 timer;优先使用真实 audio engine / MediaSession 的位置时钟。 -- 如果宿主只能低频提供 position,renderer 仍可工作,但逐词运动和 seek 判定精度会随采样精度下降;必要时可关闭 auto seek detection,但真实跳转仍应调用 `state.seekTo(...)`。 -- 曲末判定来自所有主歌词 group 的最大 `endTimeMs`,不需要单独同步 media duration/end signal。 -- emphasized grapheme glow 在 API 26+ 使用 native text shadow,直接采用 upstream 的零偏移、白色 shadow、em blur 半径和动画 alpha,不依赖 API 31 `RenderEffect`。 -- ruby、主字与逐词 roman 共用同一个 word mask;ruby segment 的时间会驱动整个 visual word box 的扫描,宿主不需要额外同步 annotation progress。 -- API 31+ 的歌词行距离模糊使用原生 blur effect;Android 8-11 会保留其它视觉层级而不强行使用不可用的 Gaussian RenderEffect。 -- 手动触摸/滚轮滚动会暂停 auto-align;滚动与惯性停止后默认再等待 5 秒恢复。 -- `horizontalPadding = Dp.Unspecified` 是默认值,表示使用 AMLL 的响应式 20dp/1em 规则;只有确实需要固定边距时再显式传 Dp。 -- `enableSpring = false` 不会让 transform 瞬移;主歌词 scale、背景 slide/scale 与精确 focus correction 会统一回退到 500ms CSS-ease 风格 transition。 +- 一个歌词页面维持一个 state/view,不要每个 position tick 重建; +- 换歌时调用 `setLyricLines()`; +- 不要为了 renderer 额外启动独立 16ms JS timer;优先使用 native audio clock; +- 主线程只提交必要的状态更新,歌词解析/网络请求继续留在原有层; +- API 31+ 距离 blur 可以使用原生 blur effect;旧版本保持其它视觉层级而不强制软件 Gaussian blur; +- emphasized text shadow 在 API 26+ 使用 native text shadow,不依赖 API 31 `RenderEffect`; +- CI 已覆盖普通 Compose consumer 和不启用 Compose compiler plugin 的 View consumer。 -## 10. 当前边界 +## 13. 当前边界 -当前重点是 AMLL 动态逐词歌词的 Android-native parity。仍在继续收敛的部分包括: +仍需继续验证的主要是: -- emphasized text-shadow 的半径、颜色、alpha envelope、零偏移和逐 grapheme transform 已与 upstream 对齐,但浏览器 CSS 与 Android/Skia 的 blur kernel 仍可能产生少量像素级栅格差异; -- ruby/逐词 roman 已与主字共用 word-level mask,并按 annotation-aware word box 与 ruby segment 时间推进;剩余差异主要来自 DOM 与 Compose/Skia 的字体度量、字形 shaping 和亚像素排版; -- 其它较少使用的上游配置与平台细节仍可继续补齐; -- 更多针对实际 YAQMC 大型歌词数据的性能压测。 +- 浏览器 CSS 与 Android/Skia shadow blur kernel 的少量像素差异; +- DOM 与 Compose/Skia 在字体度量、shaping、亚像素排版上的残余差异; +- 较少使用的 upstream 配置; +- YAQMC 真实大型逐词歌词、低端设备和长时间播放的性能/内存表现; +- YAQMC 主仓 package 权限和真正的 native overlay 集成。 -这些不会改变上面的核心接入方式;后续 parity 更新应尽量保持 `LyricLine -> AMLLPlayerState -> AMLLPlayer` 这一层 API 稳定。 \ No newline at end of file +这些边界不改变稳定宿主接口:Compose 使用 `LyricLine -> AMLLPlayerState -> AMLLPlayer`,传统 Android 使用 `LyricLine -> AMLLPlayerView`。