diff --git a/AGENTS.md b/AGENTS.md index 2088384..23011c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,7 +165,7 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.5.3"` / `versionCode 35`(18~21 被本机临时诊断包占用过) +- `app/build.gradle`:`versionName "1.5.4"` / `versionCode 36`(18~21 被本机临时诊断包占用过) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`): 任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。 @@ -262,6 +262,15 @@ $BT/apksigner verify 目标名.apk 点歌失败时先搜远程日志里的 **`JS `** 与 **`点歌失败`** 两行:前者给 「Try source / Token OK/FAIL / do.php code=」的逐步过程,后者给 JS 汇总的失败原因。 - **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底) + - ⚠️ **`gz.ac16.vip` 是"源站入口"而不是"广告主机"的典型例子(2026-10 实测)**: + 该域名对任何 `/<编号>.ts` 都返回**同一段广告视频**,因此它留在 `AdVideoFilter.AD_HOSTS` + 里(媒体层拦截,必须保留);但它的 `i.php` + `music/do.php` 与其他入口同一后台、同一协议, + **会下发指向真源 CDN(txog.ktvsky.com / download.origjoy.com)的逐曲地址**, + 实测可下载且逐曲内容不同。因此已把它**置于源列表首位** + (`ktv_api.js` 的 `CONFIG.HOSTS` 与 `SongUrlResolver.DEFAULT_HOSTS` 各一处)。 + **判定原则**:广告黑名单只作用于**返回地址(媒体 URL)的 host**, + **不作用于"取址入口"的 host** —— 两者不能混为一谈,否则会误杀唯一可用的源。 + - 该改动同时把 `KTV_BRIDGE_API` 由 9 提升到 **10**(`ktv_api.js` 两处标记 + `KtvJsBridge` 两处校验)。 - **⚠️ 取址有两条实现,改协议必须同时改**:JS 主路径(`ktv_api.js`)+ 原生兜底 (`SongUrlResolver.kt`,供 **WebView 不可用**的老盒子使用 —— 实测极米 H1S/GMUI2.0 上 WebView 导不出 `window.KtvApi`,桥永远 not ready,只靠 JS 时表现为 @@ -328,6 +337,19 @@ $BT/apksigner verify 目标名.apk 结果按进程缓存,网络切换时由 `remoteNetworkMonitor` 调用 `DeviceMac.refresh()` 重解析。 消费方:`KtvJsBridge.DEVICE_MAC`(JS 端 `mac`)、whoami 心跳的 `_mac`/`androidid`/`_cid` (抓包时三者同值,保持一致) +- **⚠️ 下载列表的两条约定(2026-10)**: + 1. **任务必须在"发起下载之前"登记**(`downloadSong()` 开头就把 `DownloadTask` 放进 `downloads`, + 状态「排队中」)。旧实现只在 `onDownloadStart` 里建任务,于是**取址失败** + (「无法生成下载地址」)这类还没真正开始下载的失败**不留任何记录** —— + 用户点歌失败后下载列表空空如也,既看不到原因也不能重试。 + 失败时由既有回调就地改成「失败:原因」,条目保留在列表里。 + 2. **下载列表的「删除」按状态区分语义**(`renderSongList` 的 `onDelete` 接线): + 有本地文件 → 删除文件(`confirmDeleteLocalFile`,同时清下载记录); + 失败/进行中(无文件)→ **移出下载列表**(`confirmRemoveDownloadEntry`)。 + 「移出列表」只删记录 + `stateDatabase.removeDownload`,**绝不动任何歌曲文件** —— + 历史上"顺手清缓存"曾把整个公放曲库删光(1.4.10 事故)。 + 另外 `SongListAdapter` 的「更多」图标已从装饰改为**可点/可聚焦**(遥控器可达), + 下载列表行尾的删除按钮一直都在。 - **播放信息实时刷新**:`refreshNowPlayingLabels()` 是播放界面左上角「当前播放 / 下一首」、首页播放条、 旧版播放区与顶部状态栏的**唯一**刷新入口,只在文本变化时写 View;已挂到 `lyricTicker`(500ms)上, 因此队列变化(本地点歌 / 扫码点歌 / 自动切歌)最迟 500ms 内必然同步。新增播放信息展示时请复用它, diff --git a/app/build.gradle b/app/build.gradle index b5c8717..90df804 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 35 - versionName "1.5.3" + versionCode 36 + versionName "1.5.4" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 diff --git a/app/src/main/assets/mobile/ktv_api.js b/app/src/main/assets/mobile/ktv_api.js index 41ae1b8..1f3f6b8 100644 --- a/app/src/main/assets/mobile/ktv_api.js +++ b/app/src/main/assets/mobile/ktv_api.js @@ -12,7 +12,7 @@ (function(global) { 'use strict'; - // KTV_BRIDGE_API: 9 (Android 缓存校验标记,请勿删除) + // KTV_BRIDGE_API: 10 (Android 缓存校验标记,请勿删除) // ─── 配置 ────────────────────────────────────────── const CONFIG = { @@ -23,6 +23,9 @@ VER: '4', // 歌曲源(均为同一后台的多个入口; 运行时按可用性自动排序, 解析失败的源沉底)。 HOSTS: [ + // 2026-10 实测: 其余入口对每首歌统一返回广告/测试资源时,本入口仍稳定下发真源 CDN 地址, + // 故置于首位;它自己域名下的媒体始终是广告,由 AdVideoFilter 继续拦截。 + 'http://gz.ac16.vip', 'http://w.w345.my', 'http://desktok.top', 'http://mm.kk456.top', @@ -441,7 +444,7 @@ // ─── 导出 ────────────────────────────────────────── global.KtvApi = KtvApi; global.KtvApiConfig = CONFIG; - global.KtvBridgeApiVersion = 9; + global.KtvBridgeApiVersion = 10; if (typeof module !== 'undefined' && module.exports) { module.exports = {KtvApi: KtvApi, CONFIG: CONFIG}; diff --git a/app/src/main/java/com/local/ktv/KtvJsBridge.kt b/app/src/main/java/com/local/ktv/KtvJsBridge.kt index 098b2e1..ac5b266 100644 --- a/app/src/main/java/com/local/ktv/KtvJsBridge.kt +++ b/app/src/main/java/com/local/ktv/KtvJsBridge.kt @@ -335,7 +335,7 @@ class KtvJsBridge(context: Context) { runCatching { file.isFile && file.length() in 1_001..500_000 && file.readText(Charsets.UTF_8).let { - it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 9") + it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 10") } }.getOrDefault(false) @@ -361,7 +361,7 @@ class KtvJsBridge(context: Context) { val script = String(bytes, StandardCharsets.UTF_8) require( script.length > 1000 && script.contains("KtvApi") && - script.contains("KTV_BRIDGE_API: 9"), + script.contains("KTV_BRIDGE_API: 10"), ) { "Invalid or incompatible JS file" } tempFile.writeText(script, Charsets.UTF_8) if (!tempFile.renameTo(cacheFile)) { diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index 4afa9f7..e9bc2a0 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -5441,6 +5441,20 @@ class MainActivity : AppCompatActivity() { if (::stateDatabase.isInitialized) runCatching { stateIo.execute { stateDatabase.updateDownload(song, "queued", 0) } } + // ⚠️ 必须在**发起下载之前**就把任务挂进下载列表。 + // 旧实现只在 onDownloadStart 里建任务,于是"取址失败"(无法生成下载地址)这类 + // 还没真正开始下载的失败**不会留下任何记录** —— 用户点歌失败后下载列表空空如也, + // 既看不到原因也无法重试。现在先登记为「排队中」,任何失败都会就地改成「失败:原因」。 + main.post(Runnable { + val task = downloads.firstOrNull { stableId(it.song) == stableId(song) } + ?: DownloadTask(song).also { downloads.add(it) } + if (task.state == "等待" || task.state.isEmpty() || task.state.startsWith("失败")) { + task.state = "排队中" + } + task.cancelled = false + refreshSongAdapter() + if (currentTabIndex == 6) loadDownloadedList() + }) download(song, object : DownloadCallback { override fun onDownloadStart(song: Song) { if (extraCallback != null) extraCallback.onDownloadStart(song) @@ -6275,6 +6289,13 @@ class MainActivity : AppCompatActivity() { confirmDeleteLocalFile(song) }, LinearLayout.LayoutParams(0, -2, 1f)) } + // 下载列表里额外提供「移出列表」:在下载管理中删除条目(失败任务一直留在列表里, + // 就是为了能看清原因并自行清理或重试)。 + if (browseMode == "downloads" && downloads.any { stableId(it.song) == stableId(song) }) { + actions.addView(action(R.drawable.ott_ic_delete, "移出列表") { + confirmRemoveDownloadEntry(song) + }, LinearLayout.LayoutParams(0, -2, 1f)) + } panel.addView(actions, LinearLayout.LayoutParams(-1, -2)) panel.addView(TextView(this).apply { text = song.singer.orEmpty() @@ -6309,6 +6330,41 @@ class MainActivity : AppCompatActivity() { .create().showForTv() } + /** 下载列表「移出列表」确认框。 */ + private fun confirmRemoveDownloadEntry(song: Song) { + AlertDialog.Builder(this) + .setTitle("移出下载列表") + .setMessage( + "确定把 ${song.title} 移出下载列表吗?\n" + + "只删除这条下载记录,不影响曲库;已下载的文件请用「删除文件」。", + ) + .setNegativeButton("取消", null) + .setPositiveButton("确定") { _, _ -> removeDownloadEntry(song) } + .create().showForTv() + } + + /** + * 把一首歌从下载列表中移出(同时清掉下载状态记录)。 + * + * ⚠️ 只动"下载记录",**绝不删除任何歌曲文件** —— 文件删除是用户显式选择「删除文件」的事, + * 历史上"顺手清缓存"曾把整个公放曲库删光(1.4.10 事故)。 + */ + private fun removeDownloadEntry(song: Song) { + val removing = downloads.filter { stableId(it.song) == stableId(song) } + if (removing.isEmpty()) { + toast("该歌曲不在下载列表中") + return + } + downloads.removeAll { stableId(it.song) == stableId(song) } + pendingQueueSongIds.remove(stableId(song)) + if (::stateDatabase.isInitialized) runCatching { + stateIo.execute { stateDatabase.removeDownload(song) } + } + toast("已移出下载列表:${song.title}") + refreshSongAdapter() + if (browseMode == "downloads") loadDownloadedList() + } + private fun showSongInfo(song: Song) { val message = ("歌名:" + song.title + "\n歌手:" + song.singer @@ -9825,8 +9881,16 @@ class MainActivity : AppCompatActivity() { onDownload = { song -> downloadSong(song) }, onMore = { song -> showSongActions(song) }, onTop = { song -> moveQueuedSongToNext(song) }, + // 下载列表里「删除」按状态区分语义: + // 有本地文件 → 删除文件(同时清掉下载记录) + // 失败/进行中(无文件)→ 移出下载列表(绝不误删别的文件) + // 其它列表(已点/已唱等)保持原语义:从队列移除。 onDelete = { song -> - if (browseMode == "downloads") deleteDownloadedSong(song) else deleteQueuedSong(song) + when { + browseMode != "downloads" -> deleteQueuedSong(song) + hasValidLocalSongFile(song) -> confirmDeleteLocalFile(song) + else -> confirmRemoveDownloadEntry(song) + } }, onPauseResume = { song -> toggleDownloadPause(song) }, ), diff --git a/app/src/main/java/com/local/ktv/SongListAdapter.kt b/app/src/main/java/com/local/ktv/SongListAdapter.kt index 9fc2ebd..dc54f1f 100644 --- a/app/src/main/java/com/local/ktv/SongListAdapter.kt +++ b/app/src/main/java/com/local/ktv/SongListAdapter.kt @@ -135,7 +135,7 @@ class SongListAdapter( if (!state.local || state.queued) { addView(icon(if (state.queued) R.drawable.ic_order_song else R.drawable.ott_ic_download, null), LinearLayout.LayoutParams(dp(40), dp(40))) } - addView(icon(R.drawable.ott_ic_more, null), LinearLayout.LayoutParams(dp(40), dp(40))) + addView(icon(R.drawable.ott_ic_more) { callbacks.onMore(song) }, LinearLayout.LayoutParams(dp(40), dp(40))) configureSongCard(this, song, songIndex) if (songIndex >= columns) nextFocusUpId = cardId(songIndex - columns) if (column == 1) nextFocusLeftId = cardId(songIndex - 1) @@ -167,7 +167,7 @@ class SongListAdapter( if (!state.local || state.queued) { addView(icon(if (state.queued) R.drawable.ic_order_song else R.drawable.ott_ic_download, null), LinearLayout.LayoutParams(dp(40), dp(40))) } - addView(icon(R.drawable.ott_ic_more, null), LinearLayout.LayoutParams(dp(40), dp(40))) + addView(icon(R.drawable.ott_ic_more) { callbacks.onMore(song) }, LinearLayout.LayoutParams(dp(40), dp(40))) configureSongCard(this, song, index) } } diff --git a/app/src/main/java/com/local/ktv/SongUrlResolver.kt b/app/src/main/java/com/local/ktv/SongUrlResolver.kt index 68ba0f6..0e7c903 100644 --- a/app/src/main/java/com/local/ktv/SongUrlResolver.kt +++ b/app/src/main/java/com/local/ktv/SongUrlResolver.kt @@ -47,6 +47,8 @@ object SongUrlResolver { private const val BLOCKED_COOLDOWN_MS = 3 * 60_000L private val DEFAULT_HOSTS = listOf( + // 与其他入口同一后台、同一协议;2026-10 实测唯一仍下发真源 CDN 地址的入口,放首位。 + "http://gz.ac16.vip", "http://w.w345.my", "http://desktok.top", "http://mm.kk456.top",