From fad2599e638bf2e6abc894bed2edde6ecfd979b0 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Sat, 26 Sep 2026 21:47:12 +0800 Subject: [PATCH] =?UTF-8?q?V1.5.1:=20=E4=BF=AE=E5=A4=8D"=E7=82=B9=E6=AD=8C?= =?UTF-8?q?=E6=9C=80=E5=90=8E=E4=B8=80=E9=A6=96=E6=8C=89=E5=88=87=E6=AD=8C?= =?UTF-8?q?=E2=86=92=E6=92=AD=E5=AE=8C=E5=8D=A1=E6=AD=BB"=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E6=8C=89=E8=A6=81=E6=B1=82=E6=94=B9=E4=B8=BA=E8=BD=AC?= =?UTF-8?q?=E5=85=A5=E5=85=AC=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象: 点歌清单只剩最后一首且正在播放时按一下「切歌」,那首歌播完后 App 直接卡死 (之后按切歌/播放都没反应)。 根因(advancePlayback 的出口语义 + 僵尸态): - orderQueue 是"已点清单",正在播的那首**仍在队列里**;按切歌时才移除。 - 旧逻辑: 显式切歌移除最后一首后队列已空 → 落到"清空到空闲态"分支, 该分支把 currentSong 置空、playWhenPrepared=false,**却没有停播放器**。 - 于是那首歌继续播到结束;而 onCompletion 第一行是 `if (generation != playbackGeneration || currentSong?.equals(song) != true) return` → currentSong 已为 null,回调被**直接吞掉**,再也不推进。 - 结果就是"音频结束 / currentSong 为空 / 播放中标志为 false"的僵尸态: 再按切歌或播放都没有任何反应(用户看到的卡死)。 修复(完全按用户要求的状态机): - 显式切歌且点歌清单已空(= 在最后一首上按切歌)→ **转入公放**(playRandomLocalSong(skipped)), 不再清空到空闲态。公放清单与点歌清单互不影响:随机歌曲不入 orderQueue; 用户一点歌,挂起的随机请求会被 orderQueue.isNotEmpty() 守卫取消。 - 其它出口保持原逻辑不变(队列还有歌→下一首;自然播完+autoNext+队列空→公放; 手动模式自然播完→空闲)。 - 僵尸态兜底: 所有"清空播放状态"的出口都补上 `player.stopPlayback()` + currentMediaPlayer = null (空闲态分支、公放库为空的兜底分支)。唯一例外是马上 play() 下一首的路径—— 故意不停,避免切歌瞬间因曲库扫描而静音。 AGENTS.md 增补「切歌/续播状态机」四条出口 + 僵尸态的成因与强制约定。 版本 1.5.0(31) → 1.5.1(32);签名证书不变。测试 8 例全通过。 --- AGENTS.md | 19 ++++++++++++- app/build.gradle | 4 +-- .../main/java/com/local/ktv/MainActivity.kt | 28 ++++++++++++++++++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d8105fb..499dab8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,7 +165,7 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.5.0"` / `versionCode 31`(18~21 被本机临时诊断包占用过) +- `app/build.gradle`:`versionName "1.5.1"` / `versionCode 32`(18~21 被本机临时诊断包占用过) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 @@ -453,6 +453,23 @@ $BT/apksigner verify 目标名.apk `next()` 每次都重新比对曲库,**自动吸纳新增歌曲**、剔除已删除歌曲;一轮播完自动开新一轮。 **不显示**(界面不展示)、**不关联已点清单**(`orderQueue` 完全独立,随机歌曲不入队)、 **App 退出即清理**(`onDestroy` 里 `isFinishing` 时 `clear()`,不落盘) +- **⚠️ 切歌/续播的状态机(`MainActivity.advancePlayback`)**:`orderQueue` 是"已点清单", + 正在播的那首**仍在队列里**,切歌时才移除。四条出口,改动前先对齐语义: + 1. **显式切歌**且队列还有歌 → 播下一首; + 2. **自然播完** + `autoNext` + 队列已空 → 进公放(随机本地歌曲); + 3. **显式切歌**且队列已空(= 在点歌清单**最后一首**上按切歌)→ **进公放**。 + 这是 2026-09-26 用户明确要求的行为:公放清单与点歌清单互不影响 + (随机歌曲不入 `orderQueue`;用户一点歌,挂起的随机请求就会被 + `orderQueue.isNotEmpty()` 守卫取消); + 4. 手动模式(`autoNext=false`)自然播完 → 停在空闲态等待点歌(保持旧逻辑)。 + ⚠️ **凡是要清空 `currentSong`/播放状态的出口,都必须同时停播放器** + (`player.stopPlayback()` + `currentMediaPlayer = null`,参考「清空已点」处理)。 + 原因:`onCompletion` 第一行是 + `if (generation != playbackGeneration || currentSong?.equals(song) != true) return`, + 所以只要出现"**播放器还在跑、`currentSong` 已被置空**",那首歌播完时回调被直接吞掉: + 不再推进、状态却是"无歌可播",之后按切歌/播放都没有反应 —— + 2026-09-26「点歌最后一首按切歌 → 播完卡死」就是这个僵尸态。 + 唯一例外是"马上要 `play()` 下一首"的路径(可以不停,避免切歌瞬间静音)。 - **弹窗分辨率自适应**:TV/投影/盒子的分辨率与 density 差异很大(1280x720、1920x1080, density 1.0~2.5),固定 dp 尺寸在矮屏上会被屏幕裁切。统一用 `fitDialogToScreen(dialog, scroll, content, preferredWidthDp, …)`:宽度取 `min(preferredWidthDp, 屏幕宽*0.94)`,高度把内容所在的 diff --git a/app/build.gradle b/app/build.gradle index 68a75a8..ffe77c0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 31 - versionName "1.5.0" + versionCode 32 + versionName "1.5.1" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index 1fc3214..7484ad8 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -5566,6 +5566,11 @@ class MainActivity : AppCompatActivity() { return } // 其它情况(没有公放曲目在播 / 用户主动停止):取消挂起的随机续播,进入空闲态。 + // 一并停掉播放器并清空播放器状态:只要出现"音频在跑但 currentSong 已空", + // 那首歌播完时 onCompletion 就会因 currentSong 不匹配而直接 return, + // 之后按切歌/播放都没有反应(僵尸态)。 + if (player != null) runCatching { player!!.stopPlayback() } + currentMediaPlayer = null randomLocalRequestSerial++ playbackPreparing = false playWhenPrepared = false @@ -5590,11 +5595,23 @@ class MainActivity : AppCompatActivity() { } else if (fromCompletion && autoNext && orderQueue.isEmpty()) { // 点歌清单播放完毕 → 自动续播本地已下载歌曲 playRandomLocalSong() + } else if (!fromCompletion) { + // 点歌清单**最后一首**还在播时按「切歌」→ 直接进入公放(随机本地歌曲)。 + // ⚠️ 旧逻辑在这里只把 currentSong 置空、**没有停播放器**:那首歌会继续播完, + // 而 onCompletion 的第一行是 `currentSong?.equals(song) != true → return`, + // 于是再也不会推进 —— 形成"音频已结束 / currentSong 为空 / playWhenPrepared=false" + // 的僵尸态,之后按「切歌」「播放」都毫无反应(用户报的卡死)。 + // 公放清单与点歌清单互不影响:随机歌曲不入 orderQueue,点歌时又会立即取消随机请求。 + MiliLog.info("点歌清单已空,切歌转入公放(随机本地歌曲)") + playRandomLocalSong(skipped = true) } else { - // 显式切歌但队列已空:同样取消挂起的随机续播,避免用户停歌后又被随机歌打断。 + // 手动切歌模式(autoNext=false)下自然播完:保持原逻辑,停在空闲态等待点歌。 + // 这里把播放器一并停掉并清空状态,避免出现上面那种"音频在跑、状态已清"的僵尸态。 + if (player != null) runCatching { player!!.stopPlayback() } randomLocalRequestSerial++ playbackPreparing = false playWhenPrepared = false + currentMediaPlayer = null releaseVocalPlayer() currentSong = null clearLyrics() @@ -5622,6 +5639,9 @@ class MainActivity : AppCompatActivity() { val requestSerial = ++randomLocalRequestSerial playbackPreparing = true playWhenPrepared = true + // 这里**不立刻停播放器**:本地曲库扫描(含文件校验)要花一点时间, + // 让当前这首歌继续放到下一首真正开始时再停,避免"切歌后一段静音"。 + // 僵尸态的防范放在"确实没歌可播"的分支里(见下面 selected == null 的处理)。 releaseVocalPlayer() currentSong = null clearLyrics() @@ -5662,6 +5682,12 @@ class MainActivity : AppCompatActivity() { ) play(selected) } else { + // ⚠️ 本地确实没有可播歌曲:必须**停掉播放器**再进入空闲态。 + // 否则会出现"歌曲还在放、currentSong 却已置空"的僵尸态 —— 那首歌播完时 + // onCompletion 因 `currentSong?.equals(song) != true` 直接 return, + // 之后按切歌/播放都没反应(本轮用户报的卡死正是这一类状态)。 + if (player != null) runCatching { player!!.stopPlayback() } + currentMediaPlayer = null playbackPreparing = false playWhenPrepared = false updateBottomBar(null, false)