V1.5.4: 新增可用源 gz.ac16.vip;修复"点歌失败不入下载列表";下载列表删除按状态区分
一、新增歌曲源 gz.ac16.vip(置于源列表首位) 实测(DoH 取真实 IP 154.222.26.244,绕开本机网关 fake-IP 干扰): - i.php: HTTP 200,下发 token,end_date=2027-10-05(比现用镜像源的 2026-10-05 更晚); - music/do.php: 对 7303235/7200188/8614960 分别返回各自不同的真源地址 (http://txog.ktvsky.com/.../<编号>.ts?token=... / http://download.origjoy.com/.../<编号>.ts?sign=...), 文件名均等于点歌编号; - 这些地址能真实下载:HTTP 206 video/MP2T,逐曲内容各不相同, TS 流同步字节按 188 字节对齐(41/43 包同步); - 对照:其余 7 个入口对每首歌(含不存在的编号)统一返回同一段 https://wb66.cn/f/d/7b7c9/10188555.ts(广告/测试资源),主源 w.w345.my/i.php 直接 403。 因此把 gz.ac16.vip 加入源列表:ktv_api.js 的 CONFIG.HOSTS 与 SongUrlResolver.DEFAULT_HOSTS 各一处(均置于首位)。 ⚠️ 边界:该域名**自己**的 /<编号>.ts 永远是同一段广告,所以它继续留在 AdVideoFilter.AD_HOSTS(媒体层拦截);广告黑名单只作用于"返回地址的 host", 不作用于"取址入口的 host",两者不可混淆,否则会误杀唯一可用源。 KTV_BRIDGE_API 9 → 10(ktv_api.js 两处标记 + KtvJsBridge 两处校验),保证设备热更新到新脚本。 二、修复"点歌失败时歌曲不出现在下载列表" 根因:DownloadTask 只在 onDownloadStart 里创建,所以"取址失败(无法生成下载地址)"这类 还没真正开始下载的失败**不会留下任何记录**,下载列表里什么都没有。 修复:downloadSong() 在**发起下载之前**就登记任务(状态「排队中」), 失败时由既有回调就地改成「失败:原因」,条目保留在列表里,可看原因、可重试、可清理。 三、下载列表删除功能(按状态区分语义) - 有本地文件 → 「删除文件」(confirmDeleteLocalFile,同时清掉下载记录); - 失败/进行中(无文件)→ 「移出下载列表」(confirmRemoveDownloadEntry): 只删记录 + stateDatabase.removeDownload,**绝不动任何歌曲文件** (历史上"顺手清缓存"删光公放曲库的教训见 AGENTS); - 「更多」菜单在下载列表内额外提供「移出列表」; - SongListAdapter 的「更多」图标由装饰改为**可点/可聚焦**(遥控器可达,不再只能长按)。 版本 1.5.3(35) → 1.5.4(36);签名证书不变。测试 9 例全通过。
This commit is contained in:
@@ -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 内必然同步。新增播放信息展示时请复用它,
|
||||
|
||||
+2
-2
@@ -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,否则
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) },
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user