20 Commits
Author SHA1 Message Date
zijunchiang a7a28fc45d docs(AGENTS): 记录取址双实现(JS 主路径 + SongUrlResolver 原生兜底)的同步约定 2026-09-26 17:08:51 +08:00
zijunchiang 1687f87eca V1.4.7: 新增原生取址兜底 —— 修复 WebView 不可用的老盒子无法点歌
证据(用户导出的 1.4.6 远程日志):
- 两条失败记录只有 Kotlin 侧那一行
  「点歌编号=... 详情=无法生成下载地址」,
  而 JS ...(本版新加的 console 桥)、RAW-ERR、NET-ERR、点歌失败 一条都没有。
- 即 JS 侧从未执行 —— 隐藏 WebView 在这台盒子上从未就绪
  (每条失败前正好有约 15s 的「等桥就绪」超时,时间吻合)。
- 于是 SongApiClient 只能回退 DB 的 cloud_url,取不到就报「无法生成下载地址」。

根因: 取址完全依赖 WebView 里的 ktv_api.js;GMUI2.0/Android 5.1.1 上
WebView 不可用(无法导出 window.KtvApi),桥永远 not ready。

修复:
- 新增 SongUrlResolver: 用 Kotlin 复刻 ktv_api.js 的协议
  (GET <host>/i.php 取 token → GET <host>/music/do.php 取地址),
  镜像多源排序(失败源沉底)与 isDemoUrl 校验(wb66/demo/纯IP/r2.dev/ts编号不一致)。
- 走与 JS 完全相同的裸 Socket「干净请求」通道(KtvJsBridge.rawCleanGet),
  请求特征一致故风控表现一致,且不依赖 WebView 是否就绪。
- SongApiClient.getSongDownloadUrl 三处回退点改为:
  桥未就绪 / JS 未取到地址 / 主线程调用 → 先走原生解析,再回退 DB。
- 补齐桥的生命周期可观测性: WebView 初始化失败、KtvApi 未导出、
  15s 未就绪 均写入远程日志(以前只在 logcat,这就是本轮一开始没有线索的原因)。

已用 App 的真实身份( mac=800b510bf4fd / sn=2d7bb4a7d7f7 / VER=2.0 )
从本机复现协议: i.php 200 → do.php code=200 且返回
  http://txog.ktvsky.com/.../480p/7303235.ts (与点歌编号一致),
证明参数顺序与签名算法正确。

版本 1.4.6(26) 到 1.4.7(27);签名证书不变。ktv_api.js 未改动,KTV_BRIDGE_API 仍为 8。
2026-09-26 17:08:38 +08:00
zijunchiang 691e56f617 docs(AGENTS): 补充裸 Socket 通道三坑与 JS 日志桥接约定 2026-09-26 17:00:12 +08:00
zijunchiang 0cffd08789 V1.4.6: 点歌取址失败——修裸 Socket 通道两处缺陷 + 打通 JS 侧日志
现象: 曲库正常后点歌失败「详情=无法生成下载地址」,且远程日志里没有任何线索。

排查(本地复现源站协议):
- 用同一套签名从本机请求 i.php / music/do.php 全部正常,能拿到这首歌的真实地址;
  换 App 注入的版本号(VER=2.0/VN=4.1.3.03281430) 也正常 → 源站没问题,是设备侧。
- 根因在 KtvJsBridge.rawRequest() 这条裸 Socket 通道(点歌取址正是走它):
  1. 连接阶段没有超时: `Socket(host, port)` 不受 soTimeout 约束,遇路由黑洞按系统
     TCP 超时(~2min)干等;7 个源叠加远超 JS_REQUEST_TIMEOUT_MS(180s) →
     桥返回空 → 「无法生成下载地址」,而日志里连失败都没有(请求还没失败);
  2. HTTPS 分支用 SSLSocketFactory.getDefault(),既不受
     HttpsURLConnection.setDefaultSSLSocketFactory 影响,也不受
     network_security_config 约束 → 老设备缺 ISRG 新根时握手必失败。

修复:
- rawRequest 连接改为显式 connect(timeout): Socket().connect(InetSocketAddress, t);
  HTTPS 改用 TlsCompat.sslSocketFactory()(系统信任 ∪ 内置根证书)。
- TlsCompat 暴露 sslSocketFactory(),并按 API 分级: 裸通道始终用增强工厂,
  HttpsURLConnection 默认工厂仅在 API<24 接管。
- 裸通道失败上报 RAW-ERR(原来只在 logcat)。
- 打通 JS 侧日志: wrapHtml 注入 console 桥,把 ktv_api.js 的 _log()/console.log
  转发到 android.log → MiliLog("JS ...");点歌失败时可通过
  「JS ...」逐步过程 + 「点歌失败 ... 详情=」汇总原因定位。

版本 1.4.5(25) 到 1.4.6(26);签名证书不变。ktv_api.js 未改动,KTV_BRIDGE_API 仍为 8。
2026-09-26 16:59:43 +08:00
zijunchiang 1ceba8ef7f V1.4.5: 修正下载失败的错误归因 + 清理残留临时文件(为定位"空间不足"误报)
现象: 极米 H1S 上报告「曲库分区空间不足(当前可用 6.04 GB)」——数字自相矛盾。

核实(本地实测 Muse 协议):
- 曲库压缩包 430,575,974 字节;从 gzip 尾部 ISIZE 读出解压后为 965MB,
  峰值占用约 1.4GB,6.04GB 可用空间根本不构成空间不足。
- 结论: 真正抛出的异常另有原因(消息里含 no space left),
  被 isNoSpaceError() 只按消息文本匹配而误判成了"空间不足"。

修复:
- readableError 不再只看异常消息: 报告完整异常链 + 可用空间,
  并用真实写入探针(AppPaths.probeWrite,写 8MB 再删)区分
  "真的写不进去" 与 "其实能写"(inode 耗尽/配额/ROM 限制同样报 ENOSPC,
  而 usableSpace 仍显示数 GB)。
- 失败时上报单行完整诊断到远程日志:
  DB-SYNC download failed | <异常链> | root/free/total/envState/probe/proc-mounts。
- 界面失败文案直接显示原始异常,不再用可能误导的"空间不足"结论。
- AppPaths 新增 probeWrite() 与 diagnose()(含 /proc/mounts 相关挂载点)。
- MuseDbSync 失败时同时清理 muse.db.gz.download 与 muse.db.download:
  原先失败只删前者,残留的 965MB 分片在反复重试时会白占空间。

版本 1.4.4(24) 到 1.4.5(25);签名证书不变。
2026-09-26 16:42:59 +08:00
zijunchiang a467719675 V1.4.4: 修复曲库下载期间界面卡死/极慢(高频进度回调压死主线程)
现象: 极米 H1S 上曲库开始下载后速度极慢,并有概率卡死(ANR)。

根因: MuseDbSync.downloadFile 每读一个 256KB 分块就回调一次进度,
430MB 压缩包 = 约 1700 次回调;每次回调都 main.post -> showDatabaseLoading(),
而该函数每次刷新都在主线程做:
  setText + 改 ProgressBar + setOnClickListener(null) + bringToFront()
(bringToFront 会让整棵视图树 requestLayout)。
1700 次全屏布局把主线程 Looper 队列压满 -> 界面停止响应(卡死);
同时整机 CPU 被布局任务吃掉,网络线程与 FUSE 写盘都被拖慢 -> 下载本身也变慢。
两个症状同一个根因,不是网络问题。

修复:
- 进度回调两级去重: MuseDbSync 按百分比去重; MainActivity 按最终显示文案去重,
  刷新次数从约 1700 降到不超过 100。
- showDatabaseLoading 更新路径只做真正变化的事: 文本不同才 setText、进度不同才改
  progress、不再每次 bringToFront()、不再每次重设监听。
- 解压(约 1GB)与 md5 校验(430MB)阶段补上进度明细(例如 正在解压曲库 512MB),
  避免界面长时间停在某个百分比被误判为卡死。
- 下载速率按每 10% 上报远程日志: MUSE-DB progress 30% 129MB/430MB 1.85MB/s,
  可直接区分网络慢还是磁盘慢。
- 回调签名 (Int)->Unit 扩展为 (Int, String?)->Unit 以携带明细文案。
- AGENTS.md 增补 高频回调不能直接刷 UI 条目。

版本 1.4.3(23) 到 1.4.4(24);签名证书不变。
2026-09-26 16:23:46 +08:00
zijunchiang 62216b0fb8 V1.4.3: 修复老系统(API<24)不认 Let's Encrypt 新根,导致无法下载曲库
根因(用远程日志的 STORAGE 行 + 本地复现 Muse 协议定位):
- 曲库服务 mconn.cherryonline.cn 与点歌登录 mws.cherryonline.cn 都用 Let's Encrypt 新链
  leaf ← YR2 ← Root YR ← ISRG Root X1。
- res/xml/network_security_config.xml 里虽然已把这三个根加为信任锚,但
  **该配置只在 Android 7.0(API 24)+ 被系统读取**;Android 5.1.1 上被完全忽略,
  系统信任库又没有 ISRG 新根 → SSLHandshakeException: Trust anchor for certification
  path not found → login 失败 → fetchVersion()=null → 根本不触发下载,
  界面只剩兜底文案「未找到可用曲库」,而失败只写 logcat,远程日志里看不到。
- 这也解释了为什么只有这台老设备出问题(其它设备都 >= API 24)。

修复:
- 新增 TlsCompat: 在 API<24 上把「系统默认信任 ∪ 内置根证书」的复合 X509TrustManager
  装到全局 HttpsURLConnection.setDefaultSSLSocketFactory,覆盖 App 内所有 HttpsURLConnection
  (曲库同步/JS 热更新下载/更新检查/头像下载)。主机名校验保持系统默认,不放行任意证书。
- WebView 的 TLS 无法用 SSLContext 覆盖(JS 会直连 mws): 在 KtvJsBridge 的
  onReceivedSslError 里不盲目 proceed,先用 TlsCompat.isHostTrustedByOurStore(host)
  对同一主机独立校验,通过才放行。
- TlsCompat.install 在 KtvApplication.attachBaseContext 调用(早于任何网络请求)。
- 补齐可观测性: 曲库同步每一步失败改为 MiliLog 上报
  (MUSE-DB login/sync ...、DB-SYNC download failed/空间不足/成功);
  fetchRemoteVersion 失败时设置明确的 bootstrapError,不再只显示兜底文案。
- AGENTS.md 增补「老系统 TLS 根证书」条目。

版本 1.4.2(22) → 1.4.3(23);签名证书不变。
2026-09-26 16:06:27 +08:00
zijunchiang 8ad2aa50d7 V1.4.2: 修复极米 H1S(Android 5.1.1) 一打开就闪退 —— 存储根目录写死导致
真正根因(用崩溃诊断包在该机上逐步定位得出,非此前推测的 API 守卫问题):
- AppPaths 把存储根写死成 /storage/emulated/0/MaidongKTV,该机上此路径不可写,
  ensureDirectories() 里 check(it.exists() || it.mkdirs()) 抛异常 →
  onCreate 中断 → App 一打开就闪退(该机历来所有版本均如此)。
- 定位过程: 逐条语句打启动轨迹 + 把异常直接写进轨迹通道,最终锁定
  「oc431 之后、oc432 之前」= AppPaths.ensureDirectories();包上 try/catch 后
  App 立即可正常打开,确认根因。

修复:
- AppPaths 改为运行时解析可写根目录: Environment 外部根 → 历史硬编码路径 →
  getExternalFilesDir() → filesDir,取第一个「可建且可写」的(幂等 init)。
- AppPaths.init(context) 在 KtvApplication.attachBaseContext 最前面调用,
  早于任何 AppPaths 访问;路径成员由 val 改为 getter。
- ensureDirectories() 不再抛异常,返回 Boolean;MainActivity 拿到 false 只告警继续。
- 去掉编译期内联的路径常量: MuseDatabase.VIDEO_ROOT(const→getter)、
  defaultDbFile()/findDatabaseFile() 改用 AppPaths.databaseFile
  (否则改存储根后 DB 仍指向硬编码路径)。
- 用户可见的目录提示文案改用 AppPaths.*Dir.absolutePath。
- 启动上报一行 STORAGE root=.. by=.. envState=.. probe=[..] 到远程日志,
  便于排查不同盒子的存储差异。

验证:
- DEX 中「内联的完整 DB 路径」由 1 处 → 0 处。
- 单元测试 2/2 通过;lint NewApi 闸门通过;签名证书不变(可覆盖 1.4.0/诊断包)。
- AGENTS.md 增补「存储根目录必须运行时解析」条目。

说明: versionCode 18~21 被本机临时诊断包占用,本版取 22 以便直接覆盖安装。
2026-09-26 15:53:22 +08:00
zijunchiang 40d1fec719 V1.4.1: 修复 Android 5.1.1 设备一打开就闪退(API 守卫缺失)
根因(极米 H1S / MStar 6A928 / GMUI2.0 / Android 5.1.1):
- MainActivity 把 API 23 才有的 android.media.AudioDeviceCallback 写成了
  **字段初始化器**(private val micDeviceCallback = object : ...)。
  字段初始化在 Activity 构造期执行,早于 onCreate 里的 SDK_INT 判断,
  于是下面的 registerMicInputWatcher() 版本守卫形同虚设 ——
  构造 MainActivity 即抛 NoClassDefFoundError,表现为一打开就闪退。
- 顺带修复 36 处同类未守卫的高版本 API 调用(lint NewApi 全量清零):
  * AudioDeviceCallback / AudioDeviceInfo.isSource/getType (API 23)
    → 改为可空字段 + 仅在 SDK_INT>=23 分支按需创建(@TargetApi)
  * HashMap.putIfAbsent ×5 (API 24) → MutableMap.getOrPut
  * Comparator.comparing ×3 + comparingInt ×1 (API 24) → Kotlin compareBy
    (同时移除 java.util.function 导入)
  * ConcurrentHashMap.newKeySet ×2 (API 24) → Collections.newSetFromMap
    (连带 16 处 KeySetView.add/remove/contains)
  * ConcurrentHashMap.computeIfAbsent (API 24) → ConcurrentHashMap.putIfAbsent(API 1)
  * URLConnection.getContentLengthLong ×5 (API 24) → 新增 Compat.contentLengthCompat()
  * ByteArrayOutputStream.toString(Charset) (API 33) → String(bytes, UTF_8)

防回归:
- app/build.gradle 打开 lint 闸门: checkReleaseBuilds true + abortOnError true +
  fatal += NewApi(并 disable ExpiredTargetSdkVersion,避免它挤占闸门)。
  已实测: 植入一个 NewApi 违规后 assembleRelease 直接 FAILED。
- AGENTS.md 新增「低版本设备闪退(API 守卫)」条目,记录根因、替代写法与自查命令。

版本号 1.4.0(16) → 1.4.1(17);签名证书不变,可覆盖安装。
2026-09-26 13:59:51 +08:00
zijunchiang 96c83d27b0 docs: 修复凭据 helper 作用域与令牌提取(发布时踩到的 401 根因)
- 仓库级 credential.helper(无主机限定) 会让 git 在 Gitee 认证成功后调用 store,
  把 Gitee 凭据追加写进 ~/.git-credentials-gitea(一行变两行),
  此后按单行提取的令牌变成两行拼接值,Gitea API 报 401
- 改为按主机限定的 credential.https://gitea.kasugano.cn.helper,
  并把发布小节的 TOKEN 提取改成带主机锚点的 sed -nE ... p
- 新增「发布新版本(Gitee Release)」小节:建 Release 与 attach_files(字段名 file)
  两个 API 写法、中文附件名可用、发布后必须校验下载 md5
- 纯文档改动,未改 App 代码,版本号保持 1.4.0
2026-09-26 12:52:05 +08:00
zijunchiang 08b5b438b9 docs: 登记 Gitee 发布令牌位置与用途边界
- 凭据条目拆分为 Gitea(代码主仓库) / Gitee(仅发布 Release) 两条
- 明确 ~/.git-credentials-gitee(600) 由 credential.https://gitee.com.helper 引用,
  只用于 API 建 Release/传附件,不得用于推送源码(规则 2)
- 纯文档改动,版本号保持 1.4.0
2026-09-26 12:35:27 +08:00
zijunchiang 7803d34a42 docs: 数据迁移后修正本机路径与开发环境说明
- AGENTS.md 内 4 处 /home/zijun/dsh/maidongktv → /home/zijun/dsh_data/maidongktv
  (推送方式、构建命令、签名密钥路径 ×2)
- 新增「本机工具链」小节:记录用户态 JDK 17 / git / Android SDK 布局、
  env.sh 统一入口、git 包装脚本原因、迁移导致的 mode change 处理方式
- 纯文档改动,未改 App 代码,版本号保持 1.4.0(versionCode 16)
2026-09-26 12:22:43 +08:00
zijunchiang 942ec527f1 V1.4.0: 修复断网重连后无法下载 + 网络状态检测维护
根因(断网重连后再也下载不了、日志却显示"歌曲源获取成功"):
SongApiClient.clearTokenCache() 一直是**空实现**。JS 侧 window.api.tokens 与
_apiInitPromise 因此被一直复用; 而歌曲源按「出口 IP + device」识别、试用窗口
也记在出口 IP 上(见 Eac19WindowGuard), 断网/重连(尤其换出口 IP)后旧 token 已失效 ——
取址这一步看起来成功, 真正下载却 403/失败。

1) 网络状态检测(新增 KtvNetworkMonitor)
   走 ConnectivityManager 回调(不轮询)维护全局 online, 状态变化才回调主线程;
   全局可读 online 供下载层判断失败是否由断网引起。

2) 播放页右上角「网络未连接」提示
   全屏播放页控制层 header 内、标题右侧新增红色徽标(不遮挡右上角二维码),
   断网显示、恢复自动隐藏。

3) 网络恢复/换网后的统一维护 refreshNetworkState()
   - 重建歌曲源会话: clearTokenCache() 真正下发 KtvJsBridge.resetApiSession()
     (置空 window.api 与 _apiInitPromise, 下次取址重新构造 API 实例、回到默认源顺序
      并重新登录拿新 token);
   - 复位 Eac19WindowGuard 窗口守卫; 重解析网卡 MAC + 刷新日志上下文;
   - 重启本地点歌服务并刷新二维码;
   - 自动重试断网期间失败的下载(最多 8 首)。
   断网恢复走该路径(带提示), 换网换 IP 也走同一路径(静默)。

4) 下载层网络失败跟踪(SongOkDownloadManager)
   结合 KtvNetworkMonitor.online 与异常类型(UnknownHost/Connect/SocketTimeout/
   NoRouteToHost/SSL/EOF)及 HTTP 0 判定网络类失败, 记入 networkFailed;
   成功后清除、手动取消不入集合; retryNetworkFailed() 供恢复时调用。

5) 顺带修掉一个隐患: 离线时 localIp() 为空会让 remoteNetworkMonitor 每 5s 误判
   "IP 变化"而反复重启本地点歌服务; 现已加空值短路, 离线只等网络回调。

版本号 1.3.9 -> 1.4.0(versionCode 15 -> 16)。按约定本次不发布 Release, 仅本地打包测试。
2026-09-21 03:34:29 +08:00
zijunchiang 33b704bd5a V1.3.9: 二维码跟随控制层 + 声音设置精简 + 远程滑杆绑定修复 + 搜索提速
1) 播放界面右上角二维码改为跟随控制层出现/隐藏
   setFullScreenChromeVisible() 里同步 fullScreenQrOverlay 可见性,
   updateFullScreenQrOverlay() 新建时按 fullScreenChromeVisible 初始化;
   删除按"手机是否在点歌"自动隐藏的 qrAutoHideMonitor,
   并删除扫码点歌弹窗里的"扫码点歌后自动隐藏播放界面二维码"提示词。

2) 声音设置删除音量调节与均衡器入口
   移除「歌曲最大音量」滑杆(及 pendingMusicVolume)与「均衡器入口」按钮,
   该弹窗只保留「切到点播歌曲时音量/原伴唱」这类策略选择; 音量与均衡器统一在「调音」弹窗。

3) 远程控制均衡器滑动点未完全绑定的真正原因: 滑杆被 <label> 包裹
   <label> 会把内部任意位置的点击转发给 range -> 点轨道/点文字就跳值。
   行容器 label -> div(.audio-row / .eq-band), CSS 相应改为类选择器;
   滑杆元素高度 4px -> 32px(触控目标), 细轨道改由 ::-webkit-slider-runnable-track 绘制,
   滑块 22px+5px 边框; attachThumbOnlyDrag 命中容差按 32px 滑块对齐(第二道防线)。

4) 远程搜歌界面: 歌曲移到左边、歌手移到右边
   同步把默认搜索方式改为「歌曲」(否则左侧高亮与实际模式不一致), 初始 placeholder 同步。

5) 搜索提速(基于对 991MB/67 万首 muse.db 的实测)
   实测: 中文 contains 热态 2~280ms; 拼音 GLOB 热态 0.2~150ms、冷态 1~15s;
   歌手 EXISTS 34~70ms。瓶颈是冷态 I/O 与重复查库, 因此:
   - 设备端新增 remoteSearchCache(LRU 24 条=4 查询×6 页, TTL 60s, 曲库刷新即清空),
     同一「类型+关键词+页码」直接复用 JSON, 翻页/重复搜索不再查库;
   - 手机端: AbortController 取消旧请求、同关键词不重复请求、首页返回后预热第 2 页、
     搜索期间暂停 1s 状态轮询避免抢 I/O;
   - 实测证明「一次多取几页」更慢(301 行 6.2s vs 21 行 0.7s), 故保持单页 LIMIT;
   - 均衡器卡片收起时跳过其 DOM 刷新(展开时补渲染)。

版本号 1.3.8 -> 1.3.9(versionCode 14 -> 15)。按约定本次不发布 Release, 仅本地打包测试。
2026-09-20 19:36:41 +08:00
zijunchiang 42919cc8e3 V1.3.8: 均衡器频段改横向滑杆 + 遥控确认键调节/退出 + 触控需按住滑块
1) 频段 UI 改为横向滑动条
   删除 VerticalEqBar, 新增自绘控件 EqSlider(横向), 音量与频段共用:
   每个频段一行「频率 | 滑杆 | 增益(dB)」, 带 0dB 参考线并从参考线填充到当前值。
   弹窗布局随之改为: 音量横向杆 → 均衡器开关 → 预设 → 5 行频段滑杆。

2) 电视遥控交互(按确认键才可调节控件点, 同理按确认键退出控制)
   - 焦点在滑杆上时方向键**不**改数值(先让焦点在控件间正常移动);
   - 按确认键进入「调节态」(描边变亮变粗), 此时左右/上下键才调值;
   - 再按确认键退出调节态; 调节态下 BACK 也只退出调节态、不关弹窗; 失焦自动退出。
   新增 onInteractionFinished 回调, 退出调节态/抬手/失焦时统一 applyPlaybackMode +
   saveState(音量) 或 applyEqualizerSettings(频段), 避免逐步落盘。

3) 触控优化(含远程控制): 必须按住控件点(滑块)才能滑动
   - App: EqSlider 触摸按下时先做 hitsThumb 命中判断(容差 26dp, 含纵向范围),
     未命中直接不处理 -> 点轨道空白不会跳变数值;
   - 远程点歌页面: 新增 attachThumbOnlyDrag(), 对 input[type=range] 的
     pointerdown/touchstart/mousedown 做同样的滑块命中判断, 未命中则 preventDefault,
     命中才放行浏览器原生拖动; 滑块光标改 grab/grabbing 提示可拖。

4) 自适应随之调整: 预算按「频段行数」均分得到行高(clamp 38~56dp),
   两遍 fit 超出时收缩的是频段行高(下限 34dp), 文字行仍全部 wrap_content。

版本号 1.3.7 -> 1.3.8(versionCode 13 -> 14)。按约定本次不发布 Release, 仅本地打包测试。
2026-09-20 19:00:53 +08:00
zijunchiang 7e3d233891 V1.3.7: 调音弹窗文字自适应 —— 修"文字显示不全"
问题: 上一版按分辨率压缩时把「文字行」也一起压扁了。label() 自带 6dp 上下内边距,
行高必须 >= 文本行高+12dp, 而紧凑模式给了 18dp(频段刻度)/28dp(音量标题)/42dp(标题),
sp 文本(尤其系统字体放大时)直接被裁掉 —— 这就是"文字显示不全"的根因。

修复: 改成「文字行 wrap_content + 只有无文字区域才用计算高度」
- 标题行/标题文字、音量标题、EQ 开关、预设行与预设按钮、频段数值与频率刻度
  全部改为 wrap_content, 高度由文本自身决定, 任何字体缩放下都不会被裁。
- 频段刻度新增 caption(): 无内边距 + includeFontPadding=false, 12sp 完整显示。
- 预设按钮改 includeFontPadding=false + 等距 padding, 不再被固定 46dp 压扁。
- 滑杆用自身固有高度(-2), SeekBar 无文字不会被裁。
- 两遍 fit: afterShow 里先 panel.measure() 实测内容高度, 若超出「屏幕高*0.92 - 框架」
  就把 EQ 频段区减掉(下限 dp(96)), 再调 fitDialogToScreen 收敛窗口 ——
  收缩的永远是频段区而不是文字; 仍放不下才滚动。
  新增 tuning dialog shrink band 日志便于现场核对。

版本号 1.3.6 -> 1.3.7(versionCode 12 -> 13)。按约定本次不发布 Release, 仅本地打包测试。
2026-09-20 16:11:33 +08:00
zijunchiang 0c7aa46458 V1.3.6: 「调音」与「扫码点歌」弹窗分辨率自适应
问题: 两个弹窗都用固定 dp 尺寸 + WRAP_CONTENT 高度, 在矮屏(720p@1.5≈480dp 高、
1080p@2.0≈960dp 等)上会被屏幕裁掉, 底部内容(频段滑杆/二维码说明)看不到。

修复:
- 新增通用 helper fitDialogToScreen(dialog, scroll, content, preferredWidthDp):
  宽度 = min(首选, 屏幕宽*0.94); 高度不再用 WRAP_CONTENT, 而是把内容所在的 ScrollView
  限制为 屏幕高*0.92 - 弹窗标题/按钮框架高度。先按最终宽度量一次内容原始高度,
  放得下就紧凑显示, 放不下就在弹窗内滚动 —— 任何分辨率下都不会被裁切。
- 调音弹窗: 按屏幕高度算出内容预算, 把剩余空间给 EQ 频段区并夹在 112~216dp,
  同时矮屏(高度<620dp)压缩刻度区宽度/标签/预设行高度; 输出自适应日志便于现场核对。
- 扫码点歌弹窗: 二维码边长按 屏幕高*0.42 / 屏宽*0.62 自适应并夹在 160~220(矮屏)/280dp,
  位图按最终显示尺寸生成保证清晰; 文案宽度改为 MATCH_PARENT, 内容包进 ScrollView。
- 声音设置弹窗同样接入该 helper(同一类裁切风险)。

版本号 1.3.5 -> 1.3.6(versionCode 11 -> 12)。按约定本次不发布 Release, 仅本地打包测试。
2026-09-20 15:51:52 +08:00
zijunchiang ce57068c68 V1.3.5: 调音弹窗重构(音量+均衡器) + 公放切歌修复 + 远程均衡器可折叠
1) 公放歌曲按「切歌」不再停止
   原先 advancePlayback 在已点队列为空时把「用户主动切歌」一律当作停止(进入空闲态),
   导致公放(随机)歌曲播放中按切歌就停了。现在: 若当前曲目属于公放清单
   (RandomPlayQueue.isCurrentTrack), 按切歌直接接着播清单里的下一首;
   不调用 markFinished(跳过不等于播完, 不该 +1), 但它已在 markStarted 计入本轮已播,
   所以本轮不会重复。playRandomLocalSong 新增 skipped 参数用于提示文案。

2) 「调音」弹窗重构为 App 内唯一音控入口(播放界面 + 主界面右上角同一弹窗)
   自上而下: 音量(横向控制杆, 即时生效) → 均衡器开关(默认关闭) → 均衡器预设
   (自定义/流行/摇滚/舞曲, 手改频段自动转「自定义」并保存) → EQ 频段(竖向控制杆)。
   全部即时生效, 因此没有「完成/确认」按钮, 只有关闭。
   新增自绘控件 VerticalEqBar(SeekBar 只支持横向): 触摸拖动 + 遥控器上下键步进,
   量程端点放行按键以便焦点离开, 焦点态自绘橙色边框并 suppressFill。
   均衡器预设改为 标准(仅供远程"恢复标准", 不在 App 行内)/流行/摇滚/舞曲,
   eqPreset 默认 "自定义"。
   清除旧 UI 与代码: 删除 dialog_tuning.xml / dialog_audio_control.xml /
   CircularVolumeView.kt, 移除 showAudioControlDialog / showAlignedTuningDialog /
   showEqualizerDialog / showFullScreenVolumeDialog / bindAtmospherePreset /
   updateToggleState 及 fullScreenVolume* / fullScreenToneControl /
   hideFullScreenVolumeIndicatorRunnable 等字段与 dispatchKeyEvent 中的面板分支。
   设置页「音频控制」改为指向调音弹窗(麦克风音量仍提示由功放硬件调节;
   升降调保留在下一页「音调控制」)。

3) 远程点歌页面均衡器界面不变, 均衡器文字栏改为折叠按钮(默认收起)。

版本号 1.3.4 -> 1.3.5(versionCode 10 -> 11)。按约定本次不发布 Release, 仅本地打包测试。
2026-09-20 15:38:48 +08:00
zijunchiang dcc2acb719 docs(AGENTS): 明确区分「交付」与「发布」—— 推代码/本地打包都不算发布, 发布仅由人工指令触发 2026-09-20 14:45:53 +08:00
zijunchiang 4c22826ea1 V1.3.4: 新增音频均衡器(参考 OpenEQ) + 随机续播改为伪随机公放清单
1) 音频均衡器
   - 新增 KtvEqualizer:参考开源项目 OpenEQ(Turbofan3360/OpenEQ, GPL-3.0) 的 EQUtils,
     直接用系统 android.media.audiofx.Equalizer, 按设备真实频段数与增益范围自适应
     (频段动态生成, 频段标签规则同 OpenEQ: 60/230/910/3.6K ...)。
   - 挂载方式改为按"实际播放会话": player.audioSessionId() /
     vocalPlayer.audioSessionId / embeddedVocalEngine.audioSessionId()(IJK 原生
     IjkMediaPlayer_getAudioSessionId 即真实 AudioTrack 会话)。会话随歌曲变化,
     由 500ms lyricTicker -> syncEqualizerSessions() 周期对齐, 消失会话延迟 3s 释放;
     设备不支持按会话挂载时可显式开启 globalMix(会话 0, 默认关闭)。
   - UI(showEqualizerDialog)与调音弹窗同风格: 深色圆角面板/紫色频段卡/橙色焦点反馈;
     滑杆固定 30 档、步进由设备增益范围均分(保证 0 dB 可选中), 5 个预设
     标准/流行/抒情/摇滚/唱将, 手动调整后转"自定义"。
   - 四个入口: 主页顶栏「调音」弹窗、播放界面「调音」面板、声音设置「均衡器入口」、
     远程点歌页面「均衡器」卡片(PATCH /api/v1/player/equalizer, 状态随
     GET /api/v1/state 的 equalizer 下发)。
   - 设置持久化: KtvStore.eqEnabled/eqPreset/eqLevels/eqGlobalMix。

2) 随机续播改为伪随机公放清单
   - 新增 RandomPlayQueue(纯内存): 随机续播一次性生成打乱顺序的临时清单, 一轮内不重复;
     播完一首 markFinished +1; next() 每次重新比对曲库, 自动吸纳新增歌曲、剔除已删除歌曲;
     一轮播完自动开新一轮(尽量不把刚播完的放开头)。
   - 不显示(界面不展示)、不关联已点清单(orderQueue 完全独立, 随机歌曲不入队)、
     App 退出即清理(onDestroy 里 isFinishing 时 clear(), 不落盘)。

版本号 1.3.3 -> 1.3.4(versionCode 9 -> 10)。按约定本次不发布 Release, 仅本地打包测试。
2026-09-20 12:02:07 +08:00
28 changed files with 2988 additions and 1019 deletions
+258 -15
View File
@@ -25,15 +25,30 @@
产物按内部测试约定命名 `maidong-ktv-<版本>-<改动说明>-双ABI-release.apk`,
**不创建 Release、不上传附件、不推送 tag、不在 Gitee 建 Release**。
只有收到发布指令后,才按「发布新版本」小节在 Gitea(以及需要的 Gitee)建 Release。
> ⚠️ **不要混淆"交付"与"发布"**:向 Gitea `git push origin master` 同步代码(规则 1)、
> 本地构建/签名出 `*-release.apk`(规则 4 前半段)**都不算发布**,不需要、也不得顺带
> 建 Release / 打 tag / 传附件。发布动作只有三个:建 Release、打 tag、上传附件;
> 三者的触发条件只有一个 —— **收到明确的人工发布指令**。若不确定是否已发布,
> 用 `GET /repos/{owner}/{repo}/releases` 与 `/tags` 核对,不要凭"打过包"推断。
- 凭据:Gitea 访问令牌 `dsh-sync`(可在 Gitea「设置 → 应用 → 访问令牌」随时吊销),
存放于 `~/.git-credentials-gitea`(权限 600),由本仓库级 `credential.helper` 引用;
账号密码不写入仓库配置。
- 凭据:
- **Gitea**(代码主仓库):访问令牌 `dsh-sync-20260926`(2026-09-26 数据迁移后重建;可在 Gitea
「设置 → 应用 → 访问令牌」随时吊销),存放于 `~/.git-credentials-gitea`(权限 600),
由**按主机限定**的仓库级 `credential.https://gitea.kasugano.cn.helper` 引用;
- **Gitee**(仅发布 Release,按规则 2 不推代码):访问令牌存放于 `~/.git-credentials-gitee`
(权限 600),由全局 `credential.https://gitee.com.helper` 引用;**只用于 API 建 Release /
传附件**,不得用于推送源码。
- 账号密码不写入仓库配置。
- ⚠️ **两个 helper 都必须按主机限定作用域**(`credential.https://<host>.helper`,不要用无主机限定的
`credential.helper`):git 认证成功后会调用**每个** helper 的 `store`,无主机限定会把 Gitee 凭据
追加写进 Gitea 的凭据文件(一个文件两行),此后「取第一行」式的令牌提取会拿到错误令牌、API 报 401。
- ⚠️ **提取令牌必须按主机过滤**:用 `sed -nE 's|https://[^:]+:([^@]+)@<host>.*|\1|p'`,
不要用不带主机锚点的 `sed -E 's|https://[^:]+:([^@]+)@.*|\1|'`(多行文件会输出多行、拼成错令牌)。
### 推送方式
```bash
cd /home/zijun/dsh/maidongktv/maidong-ktv
cd /home/zijun/dsh_data/maidongktv/maidong-ktv
git push origin master # origin = Gitea(唯一的代码同步目标)
```
@@ -53,7 +68,7 @@ App 内部两处端点已指向 Gitea(随本版 APK 生效):
### 发布新版本(Gitea Release)
```bash
TOKEN=$(sed -E 's|https://zijunchiang:([^@]+)@.*|\1|' ~/.git-credentials-gitea)
TOKEN=$(sed -nE 's|https://[^:]+:([^@]+)@gitea\.kasugano\.cn.*|\1|p' ~/.git-credentials-gitea)
# 1) 创建 Release(tag 需高于当前 versionName 才会提示更新)
curl -X POST -u "zijunchiang:$TOKEN" -H "Content-Type: application/json" \
@@ -74,41 +89,189 @@ curl -X POST -u "zijunchiang:$TOKEN" \
>
> 上传 22MB APK 约需 70s,注意命令超时设置。
### 发布新版本(Gitee Release)
```bash
GT=$(sed -nE 's|https://[^:]+:([^@]+)@gitee\.com.*|\1|p' ~/.git-credentials-gitee)
# 1) 创建 Release(Gitee 会自动创建同名 tag)
curl -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" \
-d '{"tag_name":"v1.4.0","name":"麦动KTV V1.4.0","body":"更新说明","target_commitish":"master"}' \
https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases
# 2) 上传 APK 附件(表单字段名是 file,不是 attachment;用返回的 release id)
curl -X POST -H "Authorization: token $GT" \
-F "file=@麦动KTV-V1.4.0-release.apk" \
"https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases/<id>/attach_files"
```
> Gitee 附件名**可用中文**(ASCII 限制只针对 Gitea 1.27),上传 22MB 约 4s。
> 发布后务必校验下载:`curl -sL <browser_download_url> | md5sum` 应与本地发布包一致
> (Gitea 侧同样要校验 `/releases/download/<tag>/<文件>` 规范路径返回 200)。
## 二、构建与产物
### 构建(release,双 ABI)
```bash
cd /home/zijun/dsh/maidongktv/maidong-ktv
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
export ANDROID_HOME=/home/zijun/Android/Sdk
export ANDROID_SDK_ROOT=/home/zijun/Android/Sdk
cd /home/zijun/dsh_data/maidongktv/maidong-ktv
. /home/zijun/dsh_data/maidongktv/env.sh # 导入 JAVA_HOME / ANDROID_HOME / PATH
./gradlew assembleRelease
```
产物:`app/build/outputs/apk/release/app-release.apk`(已由 `app/build.gradle` 的 signingConfig 签名)
### 本机工具链(2026-09-26 数据迁移后重建)
数据迁移后的本机是**纯净环境且当前用户无 sudo**,因此工具链全部装在用户态,
不要再用系统路径(`/usr/lib/jvm/...` 不存在)。统一入口:
```bash
. /home/zijun/dsh_data/maidongktv/env.sh # 已加入 ~/.bashrc,新开 shell 自动生效
```
| 组件 | 路径 | 来源 |
|---|---|---|
| JDK 17 | `/home/zijun/tools/jdk-17`(Temurin 17.0.20.1) | Adoptium tarball 解包 |
| git | `/home/zijun/tools/bin/git`(包装脚本 → `/home/zijun/tools/gitroot/usr/bin/git`) | `apt-get download git` + `dpkg -x` |
| Android SDK | `/home/zijun/Android/Sdk`(与 `local.properties` 的 `sdk.dir` 一致) | `cmdline-tools` + `sdkmanager` |
| 已装 SDK 包 | `platforms;android-35`、`build-tools;34.0.0`、`build-tools;36.0.0`、`platform-tools` | sdkmanager |
| 签名密钥 | `/home/zijun/dsh_data/maidongktv/ktv_keystore.jks`(仓库外,勿入库) | 数据迁移 |
- `git` 包装脚本是必需的:deb 内 git 的编译期 `exec-path` 写死为 `/usr/lib/git-core`
(无 root 无法创建),包装脚本显式设置 `GIT_EXEC_PATH`/`GIT_TEMPLATE_DIR` 后才找得到
`git-remote-https` 等子命令。
- 迁移会把仓库文件权限改成 744(git 记为 1288 个纯 mode change、0 行内容变化)。
遇到大量 `mode change 100644 => 100755` 时,按索引恢复权限即可,不要当成内容改动:
`git ls-files -s -z | ... chmod`(或临时 `git config core.fileMode false`)。
### 签名与校验(单独签名/改名时)
```bash
BT=/home/zijun/Android/Sdk/build-tools/36.0.0
$BT/apksigner sign --ks /home/zijun/dsh/maidongktv/ktv_keystore.jks \
$BT/apksigner sign --ks /home/zijun/dsh_data/maidongktv/ktv_keystore.jks \
--ks-key-alias ktv --ks-pass pass:123456 --key-pass pass:123456 \
--out 目标名.apk app/build/outputs/apk/release/app-release.apk
$BT/apksigner verify 目标名.apk
```
- 密钥:`/home/zijun/dsh/maidongktv/ktv_keystore.jks`(仓库外,勿入库)
- 密钥:`/home/zijun/dsh_data/maidongktv/ktv_keystore.jks`(仓库外,勿入库)
- APK 命名约定:`麦动KTV-V<版本号>-release.apk`(对外发布)/ `maidong-ktv-<版本>-<改动说明>-双ABI-release.apk`(内部测试)
### 版本号
- `app/build.gradle`:`versionName "1.3.3"` / `versionCode 9`
- `app/build.gradle`:`versionName "1.4.7"` / `versionCode 27`(18~21 被本机临时诊断包占用过)
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
- ⚠️ **发布构建已开启 NewApi 闸门**(`lint { checkReleaseBuilds true; fatal += ['NewApi'] }`):
任何「调用了高于 `minSdk 21` 且未做版本守卫的 API」都会让 `assembleRelease` 直接失败。
被阻断时按报错位置补版本判断或改用兼容写法,**不要**把 `checkReleaseBuilds` 改回 `false`
来绕过 —— 这道闸门专为「低版本设备一打开就闪退」而设(见第三节同名条目)
## 三、关键实现约束(避免踩坑)
- **⚠️ 低版本设备闪退(API 守卫)**:`minSdk 21`,但**实际客户里有 Android 5.1.1 的机器**
(极米 H1S / MStar 6A928 / GMUI2.0)。以下三类写法会让 app 在 API 22 上**一打开就闪退**,
必须避免:
1. **把高版本 API 的类写在 `Activity`/`Fragment`/`object` 的字段初始化器里** ——
字段初始化在**构造期**执行,早于 `onCreate` 里的 `SDK_INT` 判断,因此守卫形同虚设。
v1.4.0 的闪退根因就是 `private val micDeviceCallback = object : android.media.AudioDeviceCallback() {...}`
(`AudioDeviceCallback` 是 API 23)→ 构造 `MainActivity` 时 `NoClassDefFoundError`。
正确写法:字段声明为可空、在 `SDK_INT >= 23` 分支里**按需创建**(见 `ensureMicDeviceCallback()`)。
2. **Java 8 的集合默认方法**:`Map.getOrDefault` / `Map.putIfAbsent` / `ConcurrentHashMap.newKeySet`
/ `computeIfAbsent` / `Comparator.comparing` / `List.sort` 等都是 **API 24+**,
API 21~23 上抛 `NoSuchMethodError`。替代:`getOrPut`、
`Collections.newSetFromMap(ConcurrentHashMap())`、`ConcurrentHashMap.putIfAbsent`(API 1)、
Kotlin `compareBy`。
3. 其它漏网的:`URLConnection.getContentLengthLong()`(API 24,用 `Compat.kt` 的
`contentLengthCompat()`)、`ByteArrayOutputStream.toString(Charset)`(API 33)。
- 自查命令(必须为 0 条):`./gradlew :app:lintRelease` 后
在 `app/build/reports/lint-results-release.txt` 里搜 `NewApi`。
- 根因扫描捷径:`grep -n "private val .* = object : android\." app/src/main/java/com/local/ktv/*.kt`
—— 字段初始化器里出现 `android.*` 的高版本类就是要闪退。
- **⚠️ 存储根目录必须运行时解析(不要再写死 `/storage/emulated/0`)**:
`AppPaths` 曾把根目录写死成 `/storage/emulated/0/MaidongKTV`,在**极米 H1S(GMUI2.0 / Android 5.1.1)**
上该路径不可写 → `ensureDirectories()` 的 `check(it.exists() || it.mkdirs())` 抛异常 →
**App 一打开就闪退**(这台机器历来所有版本都打不开的真正原因,2026-09-26 定位)。
现在的约定:
1. `AppPaths.init(context)` 在 `KtvApplication.attachBaseContext` **最前面**调用,按
「`Environment.getExternalStorageDirectory()` → 历史硬编码路径 → `getExternalFilesDir()`
→ `filesDir`」顺序挑**第一个真正可建且可写**的目录(幂等);
2. 所有用到根路径的地方必须走 `AppPaths.*` 的 **getter**,**绝不能用 `const val` 把路径编译期内联**
(`MuseDatabase.VIDEO_ROOT`、`MuseDatabase.defaultDbFile()` 曾经就是 `const val` 内联的,
改存储根后必须一起改成运行时取值);
3. 目录创建**不许抛异常**:`AppPaths.ensureDirectories()` 返回 `Boolean`,失败只记日志,
`MainActivity` 拿到 false 也只是告警继续,绝不能让存储问题变成闪退;
4. 用户可见的目录提示文案也要用 `AppPaths.*Dir.absolutePath` 拼,不要写死 `/sdcard/...`;
5. 启动时会往远程日志打一行 `STORAGE root=... by=... envState=... probe=[...]`,
换盒子/换 ROM 时先看这行就能确认存储根落在哪。
- 自查:`grep -rn "/storage/emulated\|/sdcard/MaidongKTV" app/src/main/java --include='*.kt'`
除 `AppPaths` 的候选清单外不应再有硬编码。
- **⚠️ 老系统(API < 24)的 TLS 根证书**:`*.cherryonline.cn`(曲库同步 `mconn` / 点歌登录 `mws`)
与 `gitea.kasugano.cn` 都用 **Let's Encrypt 新链**(`leaf ← YR2 ← Root YR ← ISRG Root X1`)。
`res/xml/network_security_config.xml` 里的三个内置根证书**只在 Android 7.0(API 24)+ 生效**,
Android 5.1.1 上被完全忽略、系统信任库又没有 ISRG 新根 →
`SSLHandshakeException: Trust anchor for certification path not found` →
曲库登录失败 →**「不能下载曲库」**(这就是极米 H1S 上第二个卡点的根因,2026-09-26 定位)。
约定:
1. `TlsCompat.install(context)`(在 `KtvApplication.attachBaseContext`)在 API<24 上把
「系统默认信任 ∪ 内置根证书」的复合 `X509TrustManager` 装到全局
`HttpsURLConnection.setDefaultSSLSocketFactory`,一次覆盖 App 内所有 `HttpsURLConnection`
(曲库同步、JS 热更新下载、更新检查、头像下载)。**主机名校验保持系统默认,绝不放行任意证书**;
2. WebView 的 TLS 信任**无法**用 SSLContext 覆盖,而 JS(`ktv_api.js`)会直连
`https://mws.cherryonline.cn`。因此在 `KtvJsBridge` 的 `onReceivedSslError` 里**绝不盲目
`proceed()`**:先用 `TlsCompat.isHostTrustedByOurStore(host)` 对同一主机独立做一次 TLS 校验,
通过才放行,否则 `cancel()`;
3. 以后替换/新增内置根证书时,`TlsCompat.BUNDLED_CERTS` 与 `network_security_config.xml`
两处要同步改(一个管 API<24、一个管 API>=24);
4. 曲库同步每一步失败都会 `MiliLog.warn("MUSE-DB ..." / "DB-SYNC ...")` 上报。
老设备上「不能下载曲库」先看远程日志这几行 —— 以前只写 logcat,界面只剩
「未找到可用曲库」兜底文案,排查极其困难。
- **⚠️ 高频回调不能直接刷 UI(曲库下载 = 430MB)**:`MuseDbSync.downloadFile` 每读一个
256KB 分块回调一次进度,430MB 就是 **约 1700 次**。若每次回调都 `main.post { 改文本 +
改进度 + bringToFront() }`,主线程 Looper 队列会被压满 —— 实测表现为**界面卡死、进度停滞**,
而且整机 CPU 被布局任务吃掉后**下载本身也变慢**(极米 H1S 上就是这个现象)。
约定:
1. 进度回调必须**按最终显示文案去重**(`MuseDbSync` 侧按百分比去重、
`showDatabaseDownloadProgress` 侧按文本去重),刷新次数上限 ~100 次;
2. `showDatabaseLoading()` 的更新路径**只做真正变化的事**:文本不同才 `setText`、
进度不同才 `progress=`、**不要每次 `bringToFront()`**(整棵树 `requestLayout`)、
不要每次重设 `setOnClickListener`;
3. 解压(约 1GB)与 md5 校验(430MB)阶段**也要报进度**(回调带明细文案,
如「正在解压曲库 512MB」),否则界面会长时间停在某个百分比、看着像卡死;
4. 下载速率会按每 10% 上报一次 `MUSE-DB progress 30% 129MB/430MB 1.85MB/s`,
判断"是网络慢还是磁盘慢"直接看这几行;
5. 新增任何"周期性/高频"回调(进度、心跳、轮询)时都要按同样标准评估主线程开销。
- **⚠️ 裸 Socket 通道(`KtvJsBridge.rawRequest`)的三个坑**:点歌取址走的正是这条通道
(为了贴近原版风控要求的"干净请求"),它**不受** `HttpsURLConnection` 默认工厂与
`network_security_config` 的任何影响:
1. **HTTPS 必须用 `TlsCompat.sslSocketFactory()`**,不能用 `SSLSocketFactory.getDefault()`——
后者是系统信任库,老设备(及 Android 7.0)没有 ISRG 新根,握手直接失败;
2. **连接必须显式给超时**:`Socket(host, port)` 的连接阶段**不受** `soTimeout` 约束,
遇路由黑洞会按系统 TCP 超时(约 2 分钟)干等;多源叠加会耗光 JS 侧
`JS_REQUEST_TIMEOUT_MS`(180s) 预算,表现为**「无法生成下载地址」且日志里什么都没有**。
正确写法:`Socket().connect(InetSocketAddress(host, port), connectTimeout)`;
3. **失败必须上报**:裸通道失败打 `RAW-ERR`、HttpURLConnection 回退失败打 `NET-ERR` 到远程日志。
- **JS 侧日志必须能进远程日志**:`ktv_api.js` 的 `_log()` 走 `console.log`(桥以 `debug:true` 实例化),
默认只进 WebView 控制台、现场无法取证。`KtvJsBridge.wrapHtml()` 已注入 console 桥把
`console.log` 转发到 `android.log` → `MiliLog.info("JS ...")`。
点歌失败时先搜远程日志里的 **`JS `** 与 **`点歌失败`** 两行:前者给
「Try source / Token OK/FAIL / do.php code=」的逐步过程,后者给 JS 汇总的失败原因。
- **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底)
- **⚠️ 取址有两条实现,改协议必须同时改**:JS 主路径(`ktv_api.js`)+ 原生兜底
(`SongUrlResolver.kt`,供 **WebView 不可用**的老盒子使用 —— 实测极米 H1S/GMUI2.0 上
WebView 导不出 `window.KtvApi`,桥永远 not ready,只靠 JS 时表现为
**「无法生成下载地址」且远程日志里一条 `JS ...` 都没有**)。
- `SongUrlResolver` 镜像了 `ktv_api.js` 的 `APP_ID/APP_KEY/SDK_KEY/HOSTS` 与
**桥注入的** `VER=2.0/VN=4.1.3.03281430`(注意不是 JS 文件里的原始值)、
以及 `isDemoUrl` 校验(wb66/demo/纯 IP/r2.dev/ts 编号与 musicno 不一致)。
源站配置若变更,JS 能热更新而它必须发版 —— 所以它**只做兜底**:
`SongApiClient.getSongDownloadUrl` 在「桥未就绪 / JS 未取到地址 / 主线程调用」
三个回退点先试原生、再回退 DB 的 `cloud_url`。
- 它必须走 `KtvJsBridge.rawCleanGet()`(与 JS 完全相同的裸 Socket 干净请求),
否则会被源站风控拒绝。
- 自查远程日志:成功是 `NATIVE-SONG 取址成功 编号=... 源=...`,失败是
`NATIVE-SONG 全部源失败 编号=... 详情=token-fail|no-url|demo-or-mismatch`;
桥侧另有 `JS-BRIDGE ...`(ready / KtvApi 未导出 / WebView 初始化失败 / 15s 未就绪)。
- **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本
- **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件
- **曲库同步时效**:默认 **30 天**(`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载)
@@ -134,6 +297,46 @@ $BT/apksigner verify 目标名.apk
客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),调用点 `MiliLog.info/notice/warn/error`
保持不变。启动上报(`reportDeviceBootInfo()`)只采集这 10 项:包名、版本号、安装时间、品牌、型号、
设备名、Android 版本、CPU 架构、内核版本、屏幕分辨率
- **网络状态与断网恢复**:`KtvNetworkMonitor`(object,走 `ConnectivityManager` 回调、不轮询)
维护全局 `online`;`MainActivity.onNetworkStateChanged()` 负责:
- 断网 → 播放页右上角 `fullScreenNetworkBadge` 显示「网络未连接」(在全屏控制层 header 内、
标题右侧,不遮挡右上角二维码);
- 恢复 → `refreshNetworkState()`:重解析网卡 MAC + 日志上下文、**重建歌曲源会话**、
复位 `Eac19WindowGuard`、重启本地点歌服务并刷新二维码、自动重试断网期间失败的下载(最多 8 首)。
换网/换 IP 时 `remoteNetworkMonitor` 走同一个 `refreshNetworkState()`(静默,不弹提示)。
⚠️ **「断网重连后再也下载不了、日志却显示歌曲源获取成功」的根因**:
`SongApiClient.clearTokenCache()` 原本是**空实现**,JS 侧 `window.api.tokens` 与
`_apiInitPromise` 会被一直复用;而歌曲源按「出口 IP + device」识别、试用窗口也记在出口 IP 上,
断网/重连(尤其换出口 IP)后旧 token 已失效 —— 取址这一步看起来成功,真正下载却 403/失败。
现在 `clearTokenCache()` 会真正下发 `KtvJsBridge.resetApiSession()`
(置空 `window.api` 与 `_apiInitPromise`,下次取址重新构造 API 实例、回到默认源顺序并重新登录)。
下载层的网络类失败由 `SongOkDownloadManager` 结合 `KtvNetworkMonitor.online` 与异常类型/HTTP 0
判定并记入 `networkFailed`,网络恢复后由 `retryNetworkFailed()` 自动重试(手动取消的不入集合)。
⚠️ 顺带修掉一个隐患:离线时 `localIp()` 为空,原 `remoteNetworkMonitor` 会每 5s 误判「IP 变化」
而反复重启本地点歌服务(并连带触发刷新);现已加空值短路,离线只等网络回调。
- **播放界面右上角二维码**:跟随全屏控制层一起出现/隐藏 —— 在
`setFullScreenChromeVisible()` 里同步 `fullScreenQrOverlay` 的可见性,`updateFullScreenQrOverlay()`
新建时按 `fullScreenChromeVisible` 初始化。**不要**再按「手机是否在点歌」自动隐藏
(原 `qrAutoHideMonitor` 已删除,扫码弹窗里的对应提示文案也一并删除)
- **远程页面滑杆(触控)**:`<input type="range">` **绝不能被 `<label>` 包裹** —— label 会把
内部任意位置的点击转发给 range,表现为"点轨道/点文字就跳值"。行容器用 `<div class="audio-row">`
/ `<div class="eq-band">`;CSS 选择器也随之用类选择器而不是标签选择器。
另外滑杆元素本身高度设 32px(触控目标够大),视觉细轨道由
`::-webkit-slider-runnable-track` / `::-moz-range-track` 绘制,滑块 22px + 5px 边框。
`attachThumbOnlyDrag()` 仍作为第二道防线(命中滑块才放行原生拖动,容差按 32px 滑块算)
- **远程搜索性能**:曲库 DB 有 **67 万首 / 约 1GB**(`muse.db`),搜索是 I/O 密集操作。
实测(同一台机器直连该 DB,`rec_score` 复合索引齐全):
- 中文 `name_trim LIKE '%kw%'`:热态 **2~280ms**(走 `idx_rec_score` 顺序扫描 + 早停,无临时排序)
- 拼音 `name_cap/name_full GLOB 'kw*'`:热态 **0.2~150ms**(前缀范围扫描 + 临时 B-tree 排序;
`A*` 这类高频前缀会扫很多行),冷态(首次读 1GB 文件)可达 **1~15s**
- 歌手检索(`EXISTS` + singers LIKE):热态 **34~70ms**
结论与对策:**瓶颈在冷态 I/O 与重复查询**,因此
1) 设备端加 `remoteSearchCache`(LRU 24 条 = 最近 4 个查询 × 6 页,TTL 60s,曲库刷新时清空),
同一「类型+关键词+页码」直接复用 JSON,翻页与重复搜索不再查库;
2) 手机端:搜索请求用 `AbortController` 取消旧请求、同关键词不重复请求、
首页返回后**预热第 2 页**(把设备缓存捂热)、搜索期间暂停 1s 的状态轮询避免抢 I/O。
⚠️ 不要试图用「一次多取几页」来省请求:实测 `LIMIT 201` 的代价远高于 `LIMIT 21`
(`name_full GLOB` 301 行 6.2s vs 21 行 0.7s,冷态),会把体验做得更差
- **焦点填充与「纯色遮罩层」**:`TvFocusStyler.install()` 会把背景整体换成**不透明**红→橙渐变,
作为遥控器焦点/按下的反馈(`prepareTvFocusableTree(window.decorView)` 与对话框的
`installTree(decorView)` 会遍历整棵树施加)。按钮/Tab/卡片/list 行需要这个反馈;但
@@ -149,8 +352,48 @@ $BT/apksigner verify 目标名.apk
统一在 `applyPlaybackMode()` 内按线性系数施加到播放器(`KtvVideoView.setPlaybackVolume` /
`selectAudioChannel`)与副音轨播放器(`vocalPlayer` / `embeddedVocalEngine`)。
**严禁调用 `AudioManager.setStreamVolume` / `adjustStreamVolume` 修改系统音量**。
三条入口必须全部收敛到 `applyPlaybackMode()`:播放界面-调音(`showFullScreenVolumeDialog()` 与
`showAudioControlDialog()` 的音乐音量滑杆)、声音设置-歌曲最大音量、远程控制-音乐音量
(`remoteAudio()` 的 `musicVolume`)。曲线是纯衰减(100% 即 unity gain),不做系统级增益;
入口只有两处,且都必须收敛到 `applyPlaybackMode()`:**「调音」弹窗**(`showTuningDialog()`,
播放界面与主界面右上角共用同一个弹窗)的音量横向控制杆、远程控制-音乐音量
(`remoteAudio()` 的 `musicVolume`)。**声音设置弹窗里不再有音量滑杆与均衡器入口**
(避免同一功能两套入口),它只保留「切到点播歌曲时音量/原伴唱」这类策略选择。曲线是纯衰减(100% 即 unity gain),不做系统级增益;
设备最终响度仍取决于系统音量,App 不再替用户改动它
- **音频均衡器**:内核 `KtvEqualizer`(参考开源项目 OpenEQ,`Turbofan3360/OpenEQ`,GPL-3.0 的
`EQUtils.kt`)——直接用系统 `android.media.audiofx.Equalizer`,**按设备真实频段数与增益范围自适应**
(频段是动态生成的,不要写死 5 段)。默认把 EQ 挂到**实际播放会话**:
`player.audioSessionId()` / `vocalPlayer.audioSessionId` / `embeddedVocalEngine.audioSessionId()`
(IJK 的原生 `IjkMediaPlayer_getAudioSessionId` 返回真实 AudioTrack 会话)。会话会随歌曲变化,
因此由 500ms 的 `lyricTicker` → `syncEqualizerSessions()` 周期对齐,消失的会话延迟 3s 释放。
`globalMix`(会话 0)是设备不支持按会话挂载时的显式开关,会影响本机所有声音,默认关闭。
均衡器已并入**「调音」弹窗**(`showTuningDialog()`,自上而下:音量横向杆 → 均衡器开关 →
预设 → EQ 频段横向杆,每个频段一行:频率 | 滑杆 | 增益),主界面右上角与播放界面「调音」
是同一个弹窗;另有远程点歌页面「均衡器」卡片(`PATCH /api/v1/player/equalizer`,状态随
`GET /api/v1/state` 的 `equalizer` 字段下发)。预设:App 内为「自定义/流行/摇滚/舞曲」
(`appPresetNames()`,手动改频段即转「自定义」并自动保存),远程页面额外保留「标准」
供「恢复标准」使用。设置持久化在 `KtvStore.eq*`;
**刻度固定 30 档**(`eqSliderSteps`),步进由设备增益范围均分,保证 0 dB 一定可选中。
音量与频段共用自绘控件 `EqSlider`(横向),已 `suppressFill` 并自绘焦点/调节描边。
⚠️ **它的交互是刻意定制的,不要换回 SeekBar**:
- **遥控**:焦点在控件上时方向键**不**改数值(先让焦点在控件间移动);按**确认键进入调节态**
(描边变亮变粗)后方向键才调值,**再按确认键退出**调节态;调节态下 BACK 只退出调节态、
不关弹窗;失焦自动退出。这是"需按确认键才可调节控件点、同理按确认键退出控制"的实现。
- **触控**:必须按住滑块(控件点)才拖动,点轨道空白不跳值(`hitsThumb` 命中判断,
容差 `thumbTouchSlop`)。远程点歌页面同规则,见 `attachThumbOnlyDrag()`(app.js,对
`input[type=range]` 做指针命中判断,未命中则 preventDefault)
- **公放随机清单**:`RandomPlayQueue`(纯内存单例)——随机续播不再「每次现随机挑一首」,
而是一次性生成打乱顺序的临时清单,一轮内不重复;播完一首 `markFinished` +1;
`next()` 每次都重新比对曲库,**自动吸纳新增歌曲**、剔除已删除歌曲;一轮播完自动开新一轮。
**不显示**(界面不展示)、**不关联已点清单**(`orderQueue` 完全独立,随机歌曲不入队)、
**App 退出即清理**(`onDestroy` 里 `isFinishing` 时 `clear()`,不落盘)
- **弹窗分辨率自适应**:TV/投影/盒子的分辨率与 density 差异很大(1280x720、1920x1080,
density 1.0~2.5),固定 dp 尺寸在矮屏上会被屏幕裁切。统一用 `fitDialogToScreen(dialog, scroll,
content, preferredWidthDp, …)`:宽度取 `min(preferredWidthDp, 屏幕宽*0.94)`,高度把内容所在的
ScrollView 限制为 `屏幕高*0.92 - 弹窗标题/按钮框架(chromeDp)` —— **不要再用 `WRAP_CONTENT` 作为
弹窗高度**。内容高度由「屏幕预算 - 固定区高度」反推(调音弹窗的 EQ 频段区就是这样自适应),
预算不足时弹窗内滚动而不是被裁掉。已应用:调音弹窗、扫码点歌弹窗、声音设置弹窗。
新增/改动弹窗时请复用该 helper,别再写死 `setLayout(w, WRAP_CONTENT)`。
⚠️ **文字行必须 wrap_content**:`label()` 自带 6dp 上下内边距,固定矮行(曾用 18/28/42dp)
会把文字裁掉 —— 这是"文字显示不全"的根因。做法是「文字行 wrap_content + 只有无文字区域
才用计算高度」:调音弹窗里唯一可伸缩的是 EQ 频段区,采用**两遍 fit**(先 `panel.measure()`
实测内容高度,超出预算就把频段区减掉,再调 `fitDialogToScreen`),这样收缩的永远是频段区
而不是文字;频段区下限 dp(96),再放不下才滚动。大字体(系统字体缩放)同理自动兜住
- **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403)
+14 -5
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv"
minSdk 21
targetSdk 25
versionCode 9
versionName "1.3.3"
versionCode 27
versionName "1.4.7"
ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
@@ -52,10 +52,19 @@ android {
noCompress 'ts', 'mp3', 'mp4', 'aac', 'm4a', 'flac', 'wav'
}
// 关闭发布构建的 lint vital 检查(本地分发的 APK 不需要 Google Play 上架要求)
// lint 策略:
// - 「调用了高于 minSdk 且未做版本守卫的 API」(NewApi) 必须**阻断发布构建**:
// 这类问题在 Android 5.x / 6.x 上直接 NoSuchMethodError / NoClassDefFoundError 闪退,
// 而本地打包测试用的都是较新设备, 极容易漏出去(v1.4.0 在极米 H1S / Android 5.1.1
// 一打开就闪退, 根因就是 AudioDeviceCallback(API 23) 被写进了 Activity 的字段初始化器)。
// - ExpiredTargetSdkVersion 默认也是 fatal 级, 但本地分发的 APK 不上架 Google Play,
// 显式关掉, 否则会挤占下面这道闸门。
// - abortOnError 必须为 true: 设为 false 会把 lintVital 的失败一并吞掉, 闸门会失效。
lint {
checkReleaseBuilds false
abortOnError false
fatal += ['NewApi']
disable += ['ExpiredTargetSdkVersion']
checkReleaseBuilds true
abortOnError true
}
// 启用 BuildConfig 生成,供 AboutFragment 读取版本号/版本Code等信息
File diff suppressed because one or more lines are too long
+149 -12
View File
@@ -1,9 +1,10 @@
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => document.querySelectorAll(selector);
const uiState = {
page: 1, query: "", searchMode: "singer", busy: false,
page: 1, query: "", searchMode: "song", busy: false,
hotPage: 1, hotPageSize: 10, hotBusy: false,
visible: true, connected: false
visible: true, connected: false, eqEnabled: false,
equalizer: null, searchAbort: null, searchKey: ""
};
function escapeHtml(value) {
@@ -81,11 +82,112 @@ async function refresh() {
$("#original").classList.toggle("active", data.vocalMode === "original");
$("#accompany").classList.toggle("active", data.vocalMode !== "original");
$("#nextSong").textContent = data.next?.title || "队列为空";
// 均衡器卡片默认收起: 收起时跳过 DOM 更新(避免每秒轮询重绘), 展开时再渲染一次。
uiState.equalizer = data.equalizer;
if (!$("#eqBody")?.classList.contains("hidden")) renderEqualizer(data.equalizer);
} catch (_) {
setConnected(false);
}
}
// ─── 均衡器(与设备端 KtvEqualizer 同步,频段由设备真实能力决定)───
function renderEqualizer(eq) {
if (!eq) return;
uiState.eqEnabled = !!eq.enabled;
const state = $("#eqState");
if (state) state.textContent = eq.enabled ? (eq.preset || "自定义") : "已关闭";
const toggle = $("#eqToggle");
if (toggle) {
toggle.textContent = eq.enabled ? "关闭均衡器" : "开启均衡器";
toggle.classList.toggle("active", !!eq.enabled);
}
const hint = $("#eqHint");
if (hint) {
hint.textContent = eq.globalMix
? "当前作用于全局混音(会影响本机所有声音)"
: "均衡器作用于本机播放输出,不改动系统音量";
}
const bands = Array.isArray(eq.bands) ? eq.bands : [];
const container = $("#eqBands");
if (container && container.childElementCount !== bands.length) {
container.innerHTML = bands.map((band, index) => `<div class="eq-band"><span>${escapeHtml(band.label)}Hz</span><b><em id="eqValue${index}">${Number(band.level).toFixed(1)}</em>dB</b><input id="eqBand${index}" type="range" min="${eq.minDb}" max="${eq.maxDb}" step="1" value="${band.level}"></div>`).join("");
bands.forEach((band, index) => {
const input = $("#eqBand" + index);
if (!input) return;
attachThumbOnlyDrag(input);
input.addEventListener("input", () => {
const value = $("#eqValue" + index);
if (value) value.textContent = Number(input.value).toFixed(1);
});
input.addEventListener("change", () => updateEqualizer({ levels: currentEqLevels() }));
});
} else if (container) {
bands.forEach((band, index) => {
const input = $("#eqBand" + index);
// 正在拖动的那根滑杆不要被轮询覆盖。
if (input && document.activeElement !== input) input.value = band.level;
const value = $("#eqValue" + index);
if (value) value.textContent = Number(band.level).toFixed(1);
});
}
const presets = Array.isArray(eq.presets) ? eq.presets : [];
const presetBox = $("#eqPresets");
if (presetBox && presetBox.childElementCount !== presets.length) {
presetBox.innerHTML = presets.map((name) => `<button type="button" class="eq-preset" data-preset="${escapeHtml(name)}">${escapeHtml(name)}</button>`).join("");
presetBox.querySelectorAll(".eq-preset").forEach((button) => {
button.addEventListener("click", () => updateEqualizer({ preset: button.dataset.preset }));
});
}
presetBox?.querySelectorAll(".eq-preset").forEach((button) => {
button.classList.toggle("active", button.dataset.preset === eq.preset);
});
}
function currentEqLevels() {
return Array.from($$("#eqBands input[type=range]")).map((input) => Number(input.value));
}
/**
* 触控优化:滑块必须按住「控件点(滑块)」才能拖动,点轨道空白处不跳变数值。
*
* `<input type="range">` 默认点轨道任意位置都会跳值并开始拖动;这里在指针按下时先做
* 命中判断 —— 命中滑块才放行浏览器原生拖动,否则 preventDefault 拦掉这次手势。
* 只有粘性标记滑块处于按下状态时才放行,手指移出轨道仍可继续拖动(由原生处理)。
*/
function attachThumbOnlyDrag(input) {
if (!input || input.dataset.thumbOnly === "1") return;
input.dataset.thumbOnly = "1";
const blocked = (event) => {
const rect = input.getBoundingClientRect();
const point = event.touches && event.touches.length ? event.touches[0] : event;
const x = point.clientX;
const y = point.clientY;
// 粗略还原轨道几何:控件左右各留半个滑块宽度。
const thumb = 32;
const trackLeft = rect.left + thumb / 2;
const trackRight = rect.right - thumb / 2;
const span = trackRight - trackLeft;
if (span <= 0 || y < rect.top - thumb || y > rect.bottom + thumb) return true;
const min = Number(input.min || 0);
const max = Number(input.max || 100);
const ratio = (Number(input.value) - min) / (max - min || 1);
const thumbX = trackLeft + ratio * span;
// 命中滑块(含手指触控容差)→ 放行拖动;否则拦掉,避免点轨道跳值。
return Math.abs(x - thumbX) > Math.max(thumb, 26);
};
const guard = (event) => { if (blocked(event)) event.preventDefault(); };
input.addEventListener("pointerdown", guard, { passive: false });
input.addEventListener("touchstart", guard, { passive: false });
input.addEventListener("mousedown", guard);
}
async function updateEqualizer(values) {
try {
await api("/api/v1/player/equalizer", { method: "PATCH", body: JSON.stringify(values) });
await refresh();
} catch (error) { toast(error.message); }
}
async function playerAction(action) {
try {
await api("/api/v1/player/actions", { method: "POST", body: JSON.stringify({ action }) });
@@ -101,31 +203,52 @@ async function updateAudio(values) {
}
async function search(reset = true) {
if (uiState.busy) return;
if (reset) {
uiState.page = 1;
uiState.query = $("#query").value.trim();
if (!uiState.query) {
const nextQuery = $("#query").value.trim();
if (!nextQuery) {
toast(`请输入${uiState.searchMode === "singer" ? "歌手" : "歌曲"}名称`);
$("#query").focus();
return;
}
// 同一关键词+同一搜索方式且结果已渲染 → 不重复请求设备(设备端也要查 1GB 曲库)。
const key = `${uiState.searchMode}|${nextQuery}`;
if (key === uiState.searchKey && $("#results").childElementCount) {
$("#searchResultBlock").scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
uiState.page = 1;
uiState.query = nextQuery;
uiState.searchKey = key;
$("#results").innerHTML = "";
$("#searchResultBlock").classList.remove("hidden");
$("#resultTitle").textContent = uiState.searchMode === "singer" ? "歌手搜索结果" : "歌曲搜索结果";
$("#resultSummary").textContent = `正在搜索“${uiState.query}”`;
$("#more").classList.add("hidden");
if (uiState.searchAbort) uiState.searchAbort.abort();
uiState.searchAbort = new AbortController();
}
const signal = uiState.searchAbort ? uiState.searchAbort.signal : undefined;
const requestedPage = uiState.page;
uiState.busy = true;
try {
const data = await api(`/api/v1/songs?q=${encodeURIComponent(uiState.query)}&type=${uiState.searchMode}&page=${uiState.page}&pageSize=20`);
const rows = (data.songs || []).map((song, index) => songRow(song, false, false, (uiState.page - 1) * 20 + index)).join("");
const data = await api(`/api/v1/songs?q=${encodeURIComponent(uiState.query)}&type=${uiState.searchMode}&page=${requestedPage}&pageSize=20`, { signal });
const rows = (data.songs || []).map((song, index) => songRow(song, false, false, (requestedPage - 1) * 20 + index)).join("");
if (rows) $("#results").insertAdjacentHTML("beforeend", rows);
else if (reset) $("#results").innerHTML = '<div class="empty">没有找到相关歌曲<br><small>请更换关键词后重试</small></div>';
$("#resultSummary").textContent = `“${uiState.query}”${data.hasMore ? ",还有更多" : ""}`;
$("#more").classList.toggle("hidden", !data.hasMore);
uiState.page += 1;
} catch (error) { toast(error.message); }
finally { uiState.busy = false; }
uiState.page = requestedPage + 1;
// 预热下一页: 设备端会把它缓存下来, 用户点「更多」时基本瞬时。
if (requestedPage === 1 && data.hasMore) {
window.setTimeout(() => {
api(`/api/v1/songs?q=${encodeURIComponent(uiState.query)}&type=${uiState.searchMode}&page=2&pageSize=20`)
.catch(() => {});
}, 500);
}
} catch (error) {
// 被下一次搜索取消属于正常情况, 不弹提示。
if (error && error.name !== "AbortError") toast(error.message);
} finally { uiState.busy = false; }
}
async function loadHot(page) {
@@ -206,9 +329,23 @@ $("#more").addEventListener("click", () => search(false));
$("#hotPrev").addEventListener("click", () => loadHot(uiState.hotPage - 1));
$("#hotNext").addEventListener("click", () => loadHot(uiState.hotPage + 1));
// 音乐音量可调; 麦克风音量由功放硬件控制, 不再向设备发送无效果请求。
attachThumbOnlyDrag($("#musicVolume"));
$("#musicVolume").addEventListener("input", (event) => { $("#musicValue").textContent = event.target.value; });
$("#musicVolume").addEventListener("change", (event) => updateAudio({ musicVolume: Number(event.target.value) }));
// 均衡器入口(与设备端弹窗等价): 开关 / 恢复标准
$("#eqToggle")?.addEventListener("click", () => updateEqualizer({ enabled: !uiState.eqEnabled }));
$("#eqReset")?.addEventListener("click", () => updateEqualizer({ preset: "标准" }));
// 均衡器文字栏作为折叠按钮: 默认收起; 展开后在同一次页面会话内保持, 重新加载页面回到收起态。
$("#eqCollapse")?.addEventListener("click", () => {
const body = $("#eqBody");
if (!body) return;
const collapsed = body.classList.toggle("hidden");
$("#eqCollapse").setAttribute("aria-expanded", collapsed ? "false" : "true");
// 展开时补一次渲染(收起期间轮询跳过了 EQ 的 DOM 更新)。
if (!collapsed) renderEqualizer(uiState.equalizer);
});
document.addEventListener("visibilitychange", () => { uiState.visible = !document.hidden; if (uiState.visible) refresh(); });
window.setInterval(() => { if (uiState.visible) refresh(); }, 1000);
// 搜索进行中暂停状态轮询: 避免与设备端 1GB 曲库查询抢 I/O。
window.setInterval(() => { if (uiState.visible && !uiState.busy) refresh(); }, 1000);
refresh();
loadHot(1);
+21 -5
View File
@@ -36,8 +36,24 @@
<article class="glass-card audio-panel">
<div class="panel-title"><div><span class="tiny-icon">♪</span><b>声音控制</b></div><small>即时生效</small></div>
<div class="seg"><button id="accompany" data-vocal="accompany"><span>伴</span>伴唱</button><button id="original" data-vocal="original"><span>原</span>原唱</button></div>
<label><span><i class="volume-icon">♪</i>音乐音量</span><b><em id="musicValue">85</em>%</b><input id="musicVolume" type="range" min="0" max="100" value="85"></label>
<label class="mic-hw"><span><i class="volume-icon mic">●</i>麦克风音量</span><small>由功放/混音器硬件调节</small><input id="micVolume" type="range" min="0" max="100" value="70" disabled></label>
<div class="audio-row"><span><i class="volume-icon">♪</i>音乐音量</span><b><em id="musicValue">85</em>%</b><input id="musicVolume" type="range" min="0" max="100" value="85"></div>
<div class="audio-row mic-hw"><span><i class="volume-icon mic">●</i>麦克风音量</span><small>由功放/混音器硬件调节</small><input id="micVolume" type="range" min="0" max="100" value="70" disabled></div>
</article>
<article class="glass-card eq-panel">
<button type="button" class="panel-title eq-title" id="eqCollapse" aria-expanded="false">
<div><span class="tiny-icon">≡</span><b>均衡器</b></div>
<span class="eq-title-right"><small id="eqState">已关闭</small><i class="eq-chevron">▾</i></span>
</button>
<div id="eqBody" class="eq-body hidden">
<div class="seg eq-switch">
<button type="button" id="eqToggle">开启均衡器</button>
<button type="button" id="eqReset">恢复标准</button>
</div>
<div id="eqBands" class="eq-bands"></div>
<div id="eqPresets" class="eq-presets"></div>
<p class="eq-hint" id="eqHint">均衡器作用于本机播放输出,不改动系统音量</p>
</div>
</article>
<article class="next-card"><div class="next-number">02</div><div><small>下一首</small><b id="nextSong">队列为空</b></div><button data-page="queue">查看队列 <span>›</span></button></article>
@@ -46,10 +62,10 @@
<section id="search" class="page">
<div class="page-heading"><div><p>SONG LIBRARY</p><h2>想唱什么?</h2></div><span>海量本地曲库</span></div>
<div class="search-mode" role="group" aria-label="搜索方式">
<button type="button" data-search-mode="singer" class="active">歌手</button>
<button type="button" data-search-mode="song">歌曲</button>
<button type="button" data-search-mode="song" class="active">歌曲</button>
<button type="button" data-search-mode="singer">歌手</button>
</div>
<form id="searchForm" class="search-box"><span class="search-icon"></span><input id="query" autocomplete="off" placeholder="输入歌手名或拼音"><button type="submit">搜索</button></form>
<form id="searchForm" class="search-box"><span class="search-icon"></span><input id="query" autocomplete="off" placeholder="输入歌曲名或拼音"><button type="submit">搜索</button></form>
<div id="searchResultBlock" class="search-result-block hidden">
<div class="block-title"><h3 id="resultTitle">歌手搜索结果</h3><span id="resultSummary"></span></div>
<div id="results" class="list"></div><button id="more" class="more hidden">加载更多歌曲</button>
+154 -20
View File
@@ -1,35 +1,169 @@
package com.local.ktv
import android.content.Context
import android.os.Environment
import android.util.Log
import java.io.File
import java.io.FileOutputStream
/** The only external-storage namespace used by the fresh Maidong KTV app. */
/**
* 麦动 KTV 的存储命名空间。
*
* ⚠️ **历史实现把根目录写死成 `/storage/emulated/0/MaidongKTV`**,在部分定制 TV 盒子
* (极米 H1S / GMUI2.0 / Android 5.1.1 = API 22)上该路径不可写:`ensureDirectories()`
* 里 `check(it.exists() || it.mkdirs())` 失败抛异常,**App 一打开就闪退**。
*
* 现在改为**运行时解析**:按「设备真实外部存储根 → 历史硬编码路径 → App 私有外部目录 →
* 内部目录」顺序挑第一个真正**可创建且可写**的目录,保证目录一定建得出来,
* 曲库/下载/状态不因存储差异而不可用。
*
* 调用约定:`Application.attachBaseContext` 里**最先**调用 [init],早于任何 AppPaths 访问。
*/
object AppPaths {
private const val TAG = "AppPaths"
/** 历史路径(作为候选之一保留,并与老版本数据保持兼容)。 */
const val ROOT_PATH = "/storage/emulated/0/MaidongKTV"
const val VIDEO_PATH = "$ROOT_PATH/video"
val root = File(ROOT_PATH)
val stateFile = File(root, "state.json")
val songsDir = File(root, "songs")
val importDir = File(root, "import")
val catalogFile = File(root, "catalog.json")
val recordsDir = File(root, "records")
val moviesDir = File(root, "movies")
val appsDir = File(root, "apps")
val backupDir = File(root, "backup")
val databaseDir = File(root, "database")
val databaseFile = File(databaseDir, "muse.db")
val videoDir = File(VIDEO_PATH)
val cloudSongsDir = File(videoDir, "cloud-song")
@Volatile private var baseDir: File = File(ROOT_PATH)
fun ensureDirectories() {
listOf(root, songsDir, importDir, recordsDir, moviesDir, appsDir, backupDir,
databaseDir, videoDir, cloudSongsDir).forEach {
check(it.exists() || it.mkdirs()) { "Cannot create ${it.absolutePath}" }
/** 命中的候选标识(诊断用)。 */
@Volatile private var resolvedBy: String = "default(not-initialized)"
/** 全部候选的探测结果(诊断用)。 */
@Volatile private var probeResult: String = ""
val root: File get() = baseDir
val stateFile: File get() = File(root, "state.json")
val songsDir: File get() = File(root, "songs")
val importDir: File get() = File(root, "import")
val catalogFile: File get() = File(root, "catalog.json")
val recordsDir: File get() = File(root, "records")
val moviesDir: File get() = File(root, "movies")
val appsDir: File get() = File(root, "apps")
val backupDir: File get() = File(root, "backup")
val databaseDir: File get() = File(root, "database")
val databaseFile: File get() = File(databaseDir, "muse.db")
val videoDir: File get() = File(root, "video")
val cloudSongsDir: File get() = File(videoDir, "cloud-song")
/** 实际使用的根目录与候选探测结果(单行,便于远程日志检索)。 */
fun storageFacts(context: Context): String {
val env = runCatching { Environment.getExternalStorageDirectory()?.absolutePath }.getOrNull()
val envState = runCatching { Environment.getExternalStorageState() }.getOrNull()
val extFiles = runCatching { context.getExternalFilesDir(null)?.absolutePath }.getOrNull()
val files = runCatching { context.filesDir?.absolutePath }.getOrNull()
return buildString {
append("root=").append(baseDir.absolutePath)
append(" by=").append(resolvedBy)
append(" writable=").append(runCatching { baseDir.canWrite() }.getOrDefault(false))
append(" env=").append(env)
append(" envState=").append(envState)
append(" extFiles=").append(extFiles)
append(" files=").append(files)
append(" probe=[").append(probeResult).append(']')
}
}
/**
* 解析出一个**真正可写**的根目录。可重复调用(幂等)。
* 放在 `Application.attachBaseContext` 最前面,保证后续任何 AppPaths 访问都拿到正确值。
*/
fun init(context: Context) {
if (resolvedBy != "default(not-initialized)") return
val app = runCatching { context.applicationContext }.getOrDefault(context)
val candidates = LinkedHashMap<String, File>()
runCatching { Environment.getExternalStorageDirectory() }.getOrNull()?.let {
candidates["env(${it.absolutePath})"] = File(it, "MaidongKTV")
}
candidates["legacy($ROOT_PATH)"] = File(ROOT_PATH)
runCatching { app.getExternalFilesDir(null) ?: app.filesDir }.getOrNull()?.let {
candidates["private(${it.absolutePath})"] = File(it, "MaidongKTV")
}
runCatching { app.filesDir }.getOrNull()?.let {
candidates["internal(${it.absolutePath})"] = File(it, "MaidongKTV")
}
val envState = runCatching { Environment.getExternalStorageState() }.getOrNull() ?: "?"
val report = StringBuilder()
candidates.forEach { (tag, dir) ->
val ok = runCatching { (dir.isDirectory || dir.mkdirs()) && dir.canWrite() }
.getOrDefault(false)
if (report.isNotEmpty()) report.append("; ")
report.append(tag).append('=').append(if (ok) "ok" else "fail")
if (ok && resolvedBy == "default(not-initialized)") {
baseDir = dir
resolvedBy = tag
}
}
probeResult = report.toString()
Log.i(TAG, "storage root = ${baseDir.absolutePath} by=$resolvedBy envState=$envState")
Log.i(TAG, "storage probe = $probeResult")
}
/** 建目录。**永不抛异常**:存储不可用时只返回 false 并记录,由上层给出明确提示,而不是闪退。 */
fun ensureDirectories(): Boolean {
var allOk = true
listOf(root, songsDir, importDir, recordsDir, moviesDir, appsDir, backupDir,
databaseDir, videoDir, cloudSongsDir).forEach { dir ->
val ok = runCatching { dir.exists() || dir.mkdirs() }.getOrDefault(false)
if (!ok) {
allOk = false
Log.w(TAG, "cannot create directory: ${dir.absolutePath}")
}
}
return allOk
}
/** The original IJK binary creates this log folder internally; remove it after native startup. */
fun removeNativeLegacyLogDirectory() {
File("/storage/emulated/0/muse").deleteRecursively()
val env = runCatching { Environment.getExternalStorageDirectory()?.absolutePath }.getOrNull()
?: "/storage/emulated/0"
runCatching { File("$env/muse").deleteRecursively() }
}
private fun size(text: Long): String =
String.format(java.util.Locale.ROOT, "%.2fGB", text / 1024.0 / 1024.0 / 1024.0)
/**
* 真实写入探针:写 [sizeMB] 兆再删掉,验证「**现在真的能写**」。
*
* `usableSpace` 只反映**块空间**;inode 耗尽、配额、FUSE/ROM 限制都会返回 ENOSPC
* 但 still 报着几 GB 可用 —— 所以判断"是不是空间问题"必须靠真实写入,而不是看数字。
*/
fun probeWrite(dir: File, sizeMB: Int = 8): String = runCatching {
val probe = File(dir, ".write_probe.tmp")
try {
val buffer = ByteArray(1024 * 1024)
FileOutputStream(probe).use { out ->
repeat(sizeMB) { out.write(buffer) }
out.flush()
out.fd.sync()
}
"ok(${sizeMB}MB)"
} finally {
runCatching { probe.delete() }
}
}.getOrElse { "${it.javaClass.simpleName}: ${it.message}" }
private fun mountLines(): String = runCatching {
File("/proc/mounts").readLines()
.filter { line ->
line.contains("sdcard") || line.contains("/data ") ||
line.contains("/storage") || line.contains("/mnt")
}
.take(12)
.joinToString(" | ") { it.substringBefore(" ").trim() }
}.getOrNull() ?: "(unreadable)"
/** 一次性诊断串:空间、真实可写性、挂载点。失败时上报远程日志并显示在界面上。 */
fun diagnose(): String {
val dir = databaseDir
val free = runCatching { dir.usableSpace }.getOrDefault(0L)
val total = runCatching { dir.totalSpace }.getOrDefault(0L)
val envState = runCatching { Environment.getExternalStorageState() }.getOrNull()
return "root=${root.absolutePath} free=${size(free)} total=${size(total)} " +
"envState=$envState probe=${probeWrite(dir)} mounts=[${mountLines()}]"
}
}
@@ -77,7 +77,7 @@ object AppUpdateManager {
}
try {
require(connection.responseCode in 200..299) { "下载失败:HTTP ${connection.responseCode}" }
val total = connection.contentLengthLong
val total = connection.contentLengthCompat()
connection.inputStream.use { input ->
FileOutputStream(partial).use { output ->
val buffer = ByteArray(128 * 1024)
@@ -1,71 +0,0 @@
package com.local.ktv
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.View
import kotlin.math.min
/** Full-screen volume indicator modelled after the original KTV circular overlay. */
class CircularVolumeView(context: Context) : View(context) {
private val density = resources.displayMetrics.density
private val arcBounds = RectF()
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var volume = 0
fun setVolume(value: Int) {
volume = value.coerceIn(0, 100)
contentDescription = "音量 $volume"
invalidate()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desired = (154f * density).toInt()
val width = resolveSize(desired, widthMeasureSpec)
val height = resolveSize(desired, heightMeasureSpec)
val size = min(width, height)
setMeasuredDimension(size, size)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val size = min(width, height).toFloat()
val center = size / 2f
val outerWidth = 12f * density
val progressWidth = 12f * density
val radius = center - outerWidth
paint.style = Paint.Style.FILL
paint.color = Color.rgb(48, 39, 102)
canvas.drawCircle(center, center, radius - progressWidth * 0.15f, paint)
paint.style = Paint.Style.STROKE
paint.strokeCap = Paint.Cap.BUTT
paint.strokeWidth = outerWidth
paint.color = Color.rgb(47, 37, 106)
canvas.drawCircle(center, center, radius, paint)
val ringRadius = radius - outerWidth * 0.52f
arcBounds.set(
center - ringRadius,
center - ringRadius,
center + ringRadius,
center + ringRadius,
)
paint.strokeWidth = progressWidth
paint.color = Color.rgb(60, 51, 111)
canvas.drawArc(arcBounds, -90f, 360f, false, paint)
paint.color = Color.rgb(151, 155, 143)
canvas.drawArc(arcBounds, -90f, volume * 3.6f, false, paint)
paint.style = Paint.Style.FILL
paint.textAlign = Paint.Align.CENTER
paint.color = Color.rgb(220, 220, 214)
paint.textSize = 30f * density
canvas.drawText(volume.toString(), center, center - 3f * density, paint)
paint.textSize = 20f * density
canvas.drawText("音量", center, center + 38f * density, paint)
}
}
+26
View File
@@ -0,0 +1,26 @@
package com.local.ktv
import android.os.Build
import java.net.URLConnection
/**
* 低版本兼容工具。
*
* 背景:`minSdk 21`,但代码里用过若干 API 22 不存在的方法,在 Android 5.x
* (如极米 H1S / GMUI2.0 / Android 5.1.1)上会在运行期抛 `NoSuchMethodError`。
* 这里把这类调用收敛成带版本判断的兼容函数。
*/
/**
* 读取 HTTP 响应体长度。
*
* `URLConnection.getContentLengthLong()` 是 **API 24(N)** 才加入的;API 21~23 上直接调用
* 会抛 `NoSuchMethodError`。低版本回退到 `getContentLength()`(int,2GB 以内等价)。
*/
fun URLConnection.contentLengthCompat(): Long =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
contentLengthLong
} else {
@Suppress("DEPRECATION")
contentLength.toLong()
}
@@ -67,11 +67,12 @@ object DatabaseBootstrapper {
}.getOrNull()
/** 下载曲库:仅官方 Muse 同步源;成功后记录同步时间(重置 30 天时效)。 */
fun download(onProgress: (Int) -> Unit): Result<File> {
fun download(onProgress: (Int, String?) -> Unit): Result<File> {
return runCatching {
val file = MuseDbSync.download(onProgress)
// MuseDbSync 内部已保存版本文件;这里记录同步时间作为时效起点。
markSynced()
MiliLog.info("DB-SYNC download OK: ${file.absolutePath} size=${formatSize(file.length())}")
file
}.mapError(::readableError)
}
@@ -87,16 +88,26 @@ object DatabaseBootstrapper {
.any { "enospc" in it || "no space left" in it }
private fun readableError(error: Throwable): Throwable {
val target = MuseDatabase.defaultDbFile()
val free = target.parentFile?.usableSpace ?: 0L
// 完整异常链:老设备上「表面原因」常常误导(例如 ENOSPC 其实不是块空间不足)。
val chain = generateSequence(error) { it.cause }
.take(5)
.joinToString(" <- ") { "${it.javaClass.simpleName}: ${it.message}" }
val diagnose = runCatching { AppPaths.diagnose() }.getOrDefault("(diagnose failed)")
MiliLog.warn("DB-SYNC download failed | $chain | $diagnose")
Log.w(TAG, "Database download failed: $chain | $diagnose")
if (isNoSpaceError(error)) {
val target = MuseDatabase.defaultDbFile()
// ⚠️ 不能只看异常消息就断言"空间不足":inode 耗尽 / 配额 / ROM 限制同样报 ENOSPC,
// 而 usableSpace 可能还显示好几 GB(本机实测:报空间不足时可用 6.04GB)。
// 用真实写入探针区分"真的写不进去" vs "其实能写"。
val probe = runCatching { AppPaths.probeWrite(target.parentFile!!) }.getOrDefault("?")
return IllegalStateException(
"曲库分区空间不足(当前可用 ${formatSize(target.parentFile?.usableSpace ?: 0L)})," +
"已清理下载临时文件,请释放空间后重试",
"曲库写入被系统拒绝(探针 $probe,可用 ${formatSize(free)})\n原始错误:$chain",
error,
)
}
val message = error.message?.takeIf { it.isNotBlank() } ?: "曲库下载失败"
Log.w(TAG, "Database download failed: $message")
return IllegalStateException(message, error)
return IllegalStateException("曲库下载失败:$chain(可用 ${formatSize(free)})", error)
}
}
+268
View File
@@ -0,0 +1,268 @@
package com.local.ktv
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
/**
* 横向控制杆 —— 「调音」弹窗的音量与均衡器频段共用。
*
* 交互按两种输入方式分别设计(对应电视遥控与触控):
*
* **遥控器**:
* - 焦点在控件上时,左右/上下键**不**改变数值 —— 先让焦点在控件之间正常移动;
* - 按「确认」键进入**调节态**(控件出现高亮描边),此时左右/上下键才改变数值;
* - 再按一次「确认」退出调节态;调节态下 BACK 也只退出调节态,不关闭弹窗;
* - 失去焦点时自动退出调节态。
*
* **触控**:
* - 必须按住滑块(控件点)才能拖动,点轨道空白处**不会**跳变数值;
* - 在滑块上按下后手指移出控件范围仍可继续拖动,直到抬起。
*
* 数值范围由 [setRange] 给出;[showZeroLine] 为 true 时(均衡器频段)会画出 0 参考线,
* 并从参考线填充到当前值,便于一眼看出提升/衰减。
*/
class EqSlider(context: Context) : View(context) {
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(60, 255, 255, 255)
style = Paint.Style.FILL
}
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(255, 184, 74)
style = Paint.Style.FILL
}
private val zeroPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(110, 255, 255, 255)
strokeWidth = 1.5f
}
private val thumbPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.FILL
}
private val focusPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(255, 152, 48)
style = Paint.Style.STROKE
strokeWidth = 2.5f
}
private val adjustingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(255, 213, 106)
style = Paint.Style.STROKE
strokeWidth = 4f
}
private val density = resources.displayMetrics.density
private val trackHeight = 8f * density
private val thumbRadius = 11f * density
/** 触摸命中滑块的水平容差;只有按住这个范围才允许拖动。 */
private val thumbTouchSlop = 26f * density
private var minValue = 0f
private var maxValue = 100f
private var steps = 100
private var level = 0f
private var showZeroLine = false
/** 是否处于遥控器「调节态」(按确认键进入/退出)。 */
var adjusting: Boolean = false
private set
/** 值变化回调(fromUser=true 表示用户操作触发)。 */
var onLevelChanged: ((level: Float, fromUser: Boolean) -> Unit)? = null
/** 一次调节结束(退出调节态 / 抬手 / 失焦)时回调,便于上层落盘。 */
var onInteractionFinished: (() -> Unit)? = null
private var dragging = false
init {
isFocusable = true
isFocusableInTouchMode = true
isClickable = true
// 自绘控件:禁止 TvFocusStyler 用不透明渐变覆盖背景(自己画焦点/调节描边)。
TvFocusStyler.suppressFill(this)
}
fun setRange(minValue: Float, maxValue: Float, steps: Int, zeroLine: Boolean = false) {
this.minValue = minValue
this.maxValue = maxValue
this.steps = steps.coerceAtLeast(1)
this.showZeroLine = zeroLine
invalidate()
}
fun level(): Float = level
fun setLevel(value: Float, notify: Boolean = false) {
val clamped = value.coerceIn(minValue, maxValue)
if (clamped == level) {
invalidate()
return
}
level = clamped
invalidate()
if (notify) onLevelChanged?.invoke(level, true)
}
private fun stepSize(): Float = (maxValue - minValue) / steps
private fun valueToRatio(value: Float): Float =
((value - minValue) / (maxValue - minValue)).coerceIn(0f, 1f)
private fun ratioToValue(ratio: Float): Float {
val raw = minValue + ratio.coerceIn(0f, 1f) * (maxValue - minValue)
// 吸附到刻度上,保证 0 一定落在某档(均衡器 0dB / 音量整数值)。
val ticks = Math.round((raw - minValue) / stepSize())
return (minValue + ticks * stepSize()).coerceIn(minValue, maxValue)
}
private fun trackLeft(): Float = paddingLeft + thumbRadius
private fun trackRight(): Float = width - paddingRight - thumbRadius
private fun thumbX(): Float =
trackLeft() + valueToRatio(level) * (trackRight() - trackLeft())
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val left = trackLeft()
val right = trackRight()
if (right <= left) return
val centerY = height / 2f
val radius = trackHeight / 2f
canvas.drawRoundRect(
RectF(left, centerY - radius, right, centerY + radius),
radius,
radius,
trackPaint,
)
// 0 参考线(均衡器频段):从参考线填充到当前值。
val zeroRatio = if (showZeroLine) ((0f - minValue) / (maxValue - minValue)).coerceIn(0f, 1f) else 0f
val zeroX = left + zeroRatio * (right - left)
if (showZeroLine) {
canvas.drawLine(zeroX, centerY - trackHeight, zeroX, centerY + trackHeight, zeroPaint)
}
val valueX = thumbX()
val fillStart = minOf(zeroX, valueX)
val fillEnd = maxOf(zeroX, valueX)
if (fillEnd - fillStart > 1f) {
canvas.drawRoundRect(
RectF(fillStart, centerY - radius, fillEnd, centerY + radius),
radius,
radius,
fillPaint,
)
}
canvas.drawCircle(valueX, centerY, thumbRadius, thumbPaint)
if (isFocused) {
val inset = 2f * density
canvas.drawRoundRect(
RectF(inset, inset, width - inset, height - inset),
8f * density,
8f * density,
if (adjusting) adjustingPaint else focusPaint,
)
}
}
/** 触摸点是否落在滑块(控件点)上。 */
private fun hitsThumb(x: Float, y: Float): Boolean {
if (y < 0 || y > height) return false
return Math.abs(x - thumbX()) <= thumbTouchSlop
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 触控优化:只有按住滑块才开始拖动,点空白轨道不跳变数值。
if (!hitsThumb(event.x, event.y)) return false
dragging = true
isPressed = true
if (!isFocused) requestFocus()
return true
}
MotionEvent.ACTION_MOVE -> {
if (!dragging) return false
val span = trackRight() - trackLeft()
if (span <= 0f) return true
val ratio = (event.x - trackLeft()) / span
val next = ratioToValue(ratio)
if (next != level) {
level = next
invalidate()
onLevelChanged?.invoke(level, true)
}
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (!dragging) return false
dragging = false
isPressed = false
performClick()
onInteractionFinished?.invoke()
return true
}
}
return super.onTouchEvent(event)
}
override fun performClick(): Boolean {
super.performClick()
return true
}
private fun step(delta: Int): Boolean {
val current = Math.round((level - minValue) / stepSize())
val next = (current + delta).coerceIn(0, steps)
if (next == current) return false
level = (minValue + next * stepSize()).coerceIn(minValue, maxValue)
invalidate()
onLevelChanged?.invoke(level, true)
return true
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val confirm = keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER ||
keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER
if (confirm) {
// 按确认键:进入 / 退出调节态。
adjusting = !adjusting
invalidate()
if (!adjusting) onInteractionFinished?.invoke()
return true
}
if (keyCode == KeyEvent.KEYCODE_BACK && adjusting) {
adjusting = false
invalidate()
onInteractionFinished?.invoke()
return true
}
val delta = when (keyCode) {
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN -> 1
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP -> -1
else -> return super.onKeyDown(keyCode, event)
}
if (!adjusting) {
// 未进入调节态:不消耗方向键,让焦点在控件之间正常移动。
return super.onKeyDown(keyCode, event)
}
step(delta)
return true
}
override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: android.graphics.Rect?) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
if (!gainFocus && adjusting) {
adjusting = false
invalidate()
onInteractionFinished?.invoke()
}
}
}
@@ -1,10 +1,28 @@
package com.local.ktv
import android.app.Application
import android.content.Context
import android.util.Log
import com.liulishuo.okdownload.OkDownload
class KtvApplication : Application() {
/**
* 存储根目录必须在**任何** AppPaths 访问之前解析好:
* 部分定制盒子(极米 H1S / Android 5.1.1)上 `/storage/emulated/0` 不可写,
* 历史实现直接崩溃。这里用 attachBaseContext 抢在所有业务代码之前完成探测。
*/
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
base?.let {
runCatching { AppPaths.init(it) }
.onFailure { error -> Log.e(TAG, "AppPaths.init failed", error) }
// 老系统(API<24)不读 network_security_config,需要代码补上内置根证书的信任,
// 否则 Let's Encrypt 新链的接口(曲库同步/点歌登录/JS 热更新)全部握手失败。
runCatching { TlsCompat.install(it) }
.onFailure { error -> Log.e(TAG, "TlsCompat.install failed", error) }
}
}
override fun onCreate() {
super.onCreate()
instanceRef = this
@@ -0,0 +1,316 @@
package com.local.ktv
import android.content.Context
import android.media.AudioManager
import android.media.audiofx.Equalizer
import android.util.Log
import kotlin.math.min
import kotlin.math.round
/**
* 音频均衡器内核。
*
* 实现参考开源项目 **OpenEQ**(https://github.com/Turbofan3360/OpenEQ ,GPL-3.0)的
* `audioprocessing/EQUtils.kt`:
* - 直接使用 Android 系统自带的 `android.media.audiofx.Equalizer`,不引入第三方 DSP;
* - **按设备真实支持的频段数与增益范围自适应**(很多设备宣称的频段数并不真正可用),
* 频段标签生成规则与 OpenEQ 的 `eqFrequenciesToLabels()` 一致(60 / 230 / 910 / 3.6K …)。
*
* 与 OpenEQ 原版的差异(本 App 是播放器而不是独立均衡器):
* - OpenEQ 挂「全局混音(会话 0)」或自己生成的会话;本 App 需要作用于**自己的播放器**,
* 因此默认把 EQ 挂到实际播放会话上:主播放器与内嵌副音轨取 IJKPlayer 的 AudioTrack 会话
* (`IjkMediaPlayer.getAudioSessionId()`,原生实现读的就是真实 AudioTrack 会话),
* 外挂音轨取 `MediaPlayer.audioSessionId`。
* - 会话随歌曲不断变化(每次 prepare 可能新建 AudioTrack),所以提供 [syncSessions]
* 由界面层周期性对齐:新会话自动挂上,消失的会话延迟释放。
* - 设备拒绝按会话挂载时,可退化为「全局混音」(会话 0)——代价是会影响本机所有声音,
* 因此做成显式开关而不是静默回退。
*/
object KtvEqualizer {
private const val TAG = "KtvEqualizer"
/** dB → 毫贝(mB),Android Equalizer 的增益单位。 */
private const val DB_TO_MB = 100f
/** 毫赫兹(mHz) → Hz,`getCenterFreq` 的单位。 */
private const val MHZ_TO_HZ = 1000f
/** 全局混音会话 id。 */
const val GLOBAL_SESSION = 0
/** 同时挂载的会话数上限(主播放器 + 副音轨 + 外挂音轨,正常 1~3 个)。 */
private const val MAX_SESSIONS = 8
/** 会话连续多少个同步周期未出现才释放(同步周期 500ms → 3s)。 */
private const val STALE_TICKS = 6
/** 探测不到设备能力时的兜底频段(常见 5 段)。 */
private val FALLBACK_FREQUENCIES = listOf(60f, 230f, 910f, 3600f, 14000f)
/** 设备均衡器能力。 */
data class Capabilities(val frequencies: List<Float>, val minDb: Float, val maxDb: Float) {
val bandCount: Int get() = frequencies.size
}
/**
* 预设曲线:以「频段在可用范围内的相对位置 0..1」为横轴、以最大增益的比例 -1..1 为纵轴。
* 这样同一套预设能自适应任意频段数的设备。
*
* `标准` 不显示在 App 的预设行里(App 用「自定义」表示用户自己调的状态),
* 但保留给远程页面的「恢复标准」按钮使用。
*/
private val PRESETS: LinkedHashMap<String, List<Pair<Float, Float>>> = linkedMapOf(
"标准" to listOf(0f to 0f),
"流行" to listOf(0f to 0.35f, 0.25f to -0.15f, 0.6f to 0.1f, 1f to 0.35f),
"摇滚" to listOf(0f to 0.7f, 0.3f to -0.4f, 1f to 0.6f),
"舞曲" to listOf(0f to 0.85f, 0.22f to 0.2f, 0.5f to -0.35f, 0.78f to 0.25f, 1f to 0.65f),
)
/** 手动改过频段后使用的预设名。 */
const val CUSTOM_PRESET = "自定义"
/** App 内「调音」弹窗预设行的顺序(含「自定义」)。 */
fun appPresetNames(): List<String> = listOf(CUSTOM_PRESET, "流行", "摇滚", "舞曲")
@Volatile var enabled: Boolean = false
private set
@Volatile var globalMix: Boolean = false
private set
@Volatile var preset: String = CUSTOM_PRESET
private set
private var levels: FloatArray = FloatArray(0)
@Volatile private var cachedCaps: Capabilities? = null
private class Entry(val effect: Equalizer) {
var missingTicks = 0
}
private val effects = LinkedHashMap<Int, Entry>()
// ─── 设备能力 ───────────────────────────────────────
fun presetNames(): List<String> = PRESETS.keys.toList()
/**
* 探测设备支持的频段与增益范围(结果缓存;失败则返回常见 5 段兜底)。
*/
fun capabilities(context: Context): Capabilities {
cachedCaps?.let { return it }
synchronized(this) {
cachedCaps?.let { return it }
val probeSession = runCatching {
(context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
.generateAudioSessionId()
}.getOrDefault(GLOBAL_SESSION)
val caps = probe(probeSession) ?: probe(GLOBAL_SESSION)
?: Capabilities(FALLBACK_FREQUENCIES, -15f, 15f)
cachedCaps = caps
ensureLevelsFor(caps.bandCount)
Log.i(
TAG,
"capabilities bands=${caps.bandCount} range=${caps.minDb}..${caps.maxDb} " +
"freq=${caps.frequencies}",
)
return caps
}
}
private fun probe(session: Int): Capabilities? = runCatching {
val eq = Equalizer(0, session)
try {
val bandCount = eq.numberOfBands.toInt()
if (bandCount <= 0) return@runCatching null
val frequencies = (0 until bandCount).map { eq.getCenterFreq(it.toShort()) / MHZ_TO_HZ }
val range = eq.bandLevelRange
Capabilities(frequencies, range[0] / DB_TO_MB, range[1] / DB_TO_MB)
} finally {
runCatching { eq.release() }
}
}.getOrNull()
/** 本机是否允许挂「全局混音」(会话 0)。 */
fun globalMixSupported(): Boolean = runCatching {
val eq = Equalizer(0, GLOBAL_SESSION)
runCatching { eq.release() }
true
}.getOrDefault(false)
/** 频段中心频率的短标签,规则同 OpenEQ:1kHz 以下直接显示,以上显示 `3.6K` 形式。 */
fun bandLabels(caps: Capabilities): List<String> = caps.frequencies.map { freq ->
val text = if (freq < 1000f) {
freq.toString()
} else {
(freq / 1000f).toString() + "K"
}
text.dropLastWhile { it == '0' }.dropLastWhile { it == '.' }.ifEmpty { "0" }
}
// ─── 状态读写 ───────────────────────────────────────
fun levelsSnapshot(): FloatArray = levels.copyOf()
fun setEnabled(value: Boolean) {
enabled = value
effects.values.forEach { entry -> runCatching { entry.effect.enabled = value } }
Log.i(TAG, "enabled=$value sessions=${effects.size}")
}
/** 切换全局混音(会话 0)。切换会重建所有效果实例。 */
fun setGlobalMix(value: Boolean) {
if (globalMix == value) return
globalMix = value
releaseAll()
Log.i(TAG, "globalMix=$value")
}
fun setLevels(newLevels: FloatArray) {
levels = newLevels.copyOf()
effects.values.forEach { applyTo(it.effect) }
}
/** 恢复当前目标频段为全 0(不平坦化)。 */
fun resetLevels() {
preset = "标准"
setLevels(FloatArray(levels.size))
}
fun applyPreset(name: String) {
val shape = PRESETS[name] ?: return
preset = name
setLevels(shapeToLevels(shape, capabilitiesIfKnown()))
}
/** 用户手动调整了某个频段:预设切换为「自定义」(不改变频段值)。 */
fun markCustomPreset() {
preset = CUSTOM_PRESET
effects.values.forEach { applyTo(it.effect) }
}
private fun capabilitiesIfKnown(): Capabilities =
cachedCaps ?: Capabilities(FALLBACK_FREQUENCIES, -15f, 15f)
/** 把 0..1 的预设曲线插值到当前设备的频段上。 */
private fun shapeToLevels(shape: List<Pair<Float, Float>>, caps: Capabilities): FloatArray {
val bandCount = caps.bandCount.coerceAtLeast(1)
val maxGain = caps.maxDb.coerceAtLeast(0f)
val minGain = caps.minDb.coerceAtMost(0f)
if (shape.size == 1) return FloatArray(bandCount) { 0f }
return FloatArray(bandCount) { index ->
val position = if (bandCount == 1) 0f else index.toFloat() / (bandCount - 1)
var upper = 1
while (upper < shape.size - 1 && shape[upper].first < position) upper++
val (leftPos, leftValue) = shape[upper - 1]
val (rightPos, rightValue) = shape[upper]
val ratio = if (rightPos - leftPos <= 0f) 0f else (position - leftPos) / (rightPos - leftPos)
val value = leftValue + (rightValue - leftValue) * ratio.coerceIn(0f, 1f)
(if (value >= 0f) value * maxGain else value * -minGain)
}
}
/**
* 载入已保存的设置(App 启动时调用一次)。
*/
fun restore(enabled: Boolean, presetName: String, savedLevels: FloatArray, useGlobalMix: Boolean) {
globalMix = useGlobalMix
this.enabled = enabled
if (savedLevels.isNotEmpty()) {
levels = savedLevels.copyOf()
}
preset = presetName.ifBlank { "标准" }
}
/**
* 载入设置后把频段对齐到设备实际频段数。
*
* 预设(非自定义)直接按预设曲线在本机频段上重新生成,避免把「在别的设备上算好的
* 频段值」硬套到本机;自定义则保留用户保存的原始数值。
*/
fun alignToDevice(caps: Capabilities) {
cachedCaps = caps
ensureLevelsFor(caps.bandCount)
if (preset != CUSTOM_PRESET) {
applyPreset(preset)
}
}
private fun ensureLevelsFor(bandCount: Int) {
val target = bandCount.coerceAtLeast(1)
if (levels.size == target) return
val old = levels
levels = FloatArray(target) { index -> old.getOrElse(index) { 0f } }
}
// ─── 会话挂载 ───────────────────────────────────────
/**
* 对齐当前存活的播放会话。
*
* @param liveSessions 主播放器 / 内嵌副音轨 / 外挂音轨当前的音频会话 id(无效的传 0)
*/
fun syncSessions(liveSessions: List<Int>) {
val targets: Set<Int> = if (globalMix) {
setOf(GLOBAL_SESSION)
} else {
liveSessions.filter { it > 0 }.toSet()
}
val iterator = effects.entries.iterator()
while (iterator.hasNext()) {
val (session, entry) = iterator.next()
if (targets.contains(session)) {
entry.missingTicks = 0
continue
}
entry.missingTicks++
if (entry.missingTicks >= STALE_TICKS) {
runCatching { entry.effect.release() }
iterator.remove()
Log.i(TAG, "released stale session=$session")
}
}
targets.forEach { attach(it) }
}
private fun attach(session: Int) {
val existing = effects[session]
if (existing != null) {
applyTo(existing.effect)
return
}
if (effects.size >= MAX_SESSIONS) {
Log.w(TAG, "skip session=$session: too many effects")
return
}
runCatching {
val eq = Equalizer(0, session)
ensureLevelsFor(eq.numberOfBands.toInt())
eq.enabled = enabled
applyTo(eq)
effects[session] = Entry(eq)
Log.i(TAG, "attached session=$session bands=${eq.numberOfBands} enabled=$enabled")
}.onFailure { Log.w(TAG, "attach session=$session failed: ${it.message}") }
}
private fun applyTo(eq: Equalizer) {
runCatching {
val loopLimit = min(levels.size, eq.numberOfBands.toInt())
for (index in 0 until loopLimit) {
eq.setBandLevel(
index.toShort(),
round(levels[index] * DB_TO_MB).toInt().toShort(),
)
}
}.onFailure { Log.w(TAG, "apply levels failed: ${it.message}") }
}
fun releaseAll() {
effects.values.forEach { runCatching { it.effect.release() } }
effects.clear()
}
/** 已挂载的会话数(供日志/诊断)。 */
fun attachedSessionCount(): Int = effects.size
}
+122 -8
View File
@@ -1,11 +1,14 @@
package com.local.ktv
import android.content.Context
import android.net.Uri
import android.net.http.SslError
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.WebView
import android.webkit.WebViewClient
import org.json.JSONObject
@@ -46,7 +49,23 @@ class KtvJsBridge(context: Context) {
// mac 取本机「当前在用网卡」的硬件地址(读不到时回退历史抓包值, 见 DeviceMac);
// sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
private val DEVICE_MAC: String get() = DeviceMac.effective()
private const val DEVICE_SN = "2d7bb4a7d7f7"
internal const val DEVICE_SN = "2d7bb4a7d7f7"
/**
* 断网重连后重置歌曲源会话。
*
* 源站按「出口 IP + device」识别,试用窗口也记在出口 IP 上(见 Eac19WindowGuard)。
* 断网/重连(尤其换了出口 IP)后旧 token 已失效,继续复用会表现为
* **「取址看起来成功、实际下载 403/失败」**。这里把整个 API 实例与初始化 Promise
* 都清掉:下一次取址会重新 new KtvApi(回到默认源顺序)并重新登录拿新 token。
*/
private const val RESET_SESSION_JS = """(function(){
try {
window.api = null;
window._apiInitPromise = null;
} catch (e) {}
return 'ok';
})()"""
}
private val appContext = context.applicationContext
@@ -79,6 +98,7 @@ class KtvJsBridge(context: Context) {
updateCache(cached)
} catch (error: Throwable) {
Log.e(TAG, "WebView init failed", error)
MiliLog.error("JS-BRIDGE WebView 初始化失败: ${error.javaClass.simpleName}: ${error.message}")
callback(false)
}
}
@@ -92,11 +112,34 @@ class KtvJsBridge(context: Context) {
settings.domStorageEnabled = true
addJavascriptInterface(NativeBridge(), "android")
webViewClient = object : WebViewClient() {
/**
* 老系统(API < 24)的 WebView 不读 network_security_config,
* 系统信任库也没有 Let's Encrypt 新根 → 访问 mws.cherryonline.cn 会直接握手失败。
* 这里**不做盲目放行**:用 [TlsCompat] 增强后的信任库对同一主机独立校验一次,
* 只有校验通过(证书链合法且主机名匹配)才继续。
*/
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?,
) {
val host = runCatching { Uri.parse(view?.url)?.host }.getOrNull()
val onlyUntrusted = error?.primaryError == SslError.SSL_UNTRUSTED
if (onlyUntrusted && host != null && TlsCompat.isHostTrustedByOurStore(host)) {
Log.w(TAG, "legacy TLS: $host 已用增强信任库复核通过,放行 WebView")
handler?.proceed()
} else {
Log.e(TAG, "SSL error NOT overridden: host=$host primary=${error?.primaryError}")
handler?.cancel()
}
}
override fun onPageFinished(view: WebView, url: String) {
view.evaluateJavascript("typeof window.KtvApi === 'function'") { result ->
if (result == "true") {
ready = true
Log.i(TAG, "JS runtime ready (${if (loadedFromCache) "cache" else "asset"})")
MiliLog.info("JS-BRIDGE ready (${if (loadedFromCache) "cache" else "asset"})")
callback(true)
} else if (loadedFromCache && !assetRetryAttempted) {
Log.w(TAG, "Cached JS failed to load; falling back to packaged asset")
@@ -105,6 +148,9 @@ class KtvJsBridge(context: Context) {
createWebView(readAssetScript(), callback)
} else {
Log.e(TAG, "KtvApi was not exported by JS")
// 老电视盒子上 WebView 不可用/被裁剪时常走这里:桥永远不就绪,
// 取址只能回退 DB → 「无法生成下载地址」。必须能远程看到。
MiliLog.error("JS-BRIDGE KtvApi 未导出,WebView 不可用")
callback(false)
}
}
@@ -117,8 +163,37 @@ class KtvJsBridge(context: Context) {
/** 桥当前是否可用。热更新重载期间会短暂为 false,调用方应据此等待而非立即回退。 */
fun isReady(): Boolean = ready && !destroyed
/**
* 供**原生兜底取址解析器**([SongUrlResolver])复用的「干净请求」通道。
*
* 与 JS 的 `android.httpGet` 走的是**完全同一条**裸 Socket 路径(只发 Host + Accept,
* 不带 UA/Connection/Accept-Encoding),因此请求特征与风控表现一致;
* 且**不依赖 WebView 是否就绪** —— 正是 WebView 不可用的老设备所需要的。
*/
fun rawCleanGet(url: String, timeoutMs: Int = 8_000): String {
val headers = JSONObject().put("Accept", "*/*").toString()
return NativeBridge().request("GET", url, null, headers, timeoutMs)
}
fun isDestroyed(): Boolean = destroyed
/**
* 重置 JS 侧歌曲源会话(断网恢复 / 下载重试时调用)。
*
* 置空 `window.api` 与 `window._apiInitPromise`,下一次取址会重新构造 API 实例
* (恢复默认源顺序)并重新登录,避免继续使用失效 token。
*/
fun resetApiSession() {
val view = webView
if (!ready || destroyed || view == null) return
mainHandler.post {
runCatching {
view.evaluateJavascript(RESET_SESSION_JS, null)
Log.i(TAG, "reset source api session")
}.onFailure { Log.w(TAG, "reset source api session failed", it) }
}
}
fun getSongUrl(
musicNo: String,
resolution: String,
@@ -238,7 +313,7 @@ class KtvJsBridge(context: Context) {
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
throw IllegalStateException("HTTP ${connection.responseCode}")
}
val declaredLength = connection.contentLengthLong
val declaredLength = connection.contentLengthCompat()
require(declaredLength < 0 || declaredLength <= MAX_RESPONSE_BYTES) { "JS file is too large" }
val bytes = connection.inputStream.use { it.readBytes() }
require(bytes.size <= MAX_RESPONSE_BYTES) { "JS file is too large" }
@@ -272,12 +347,32 @@ class KtvJsBridge(context: Context) {
private fun wrapHtml(script: String): String {
val escaped = script.replace("</script", "<\\/script", ignoreCase = true)
return "<!doctype html><html><head><meta charset=\"utf-8\"></head><body><script>$escaped</script></body></html>"
// console.log 桥接:ktv_api.js 的 _log()/_log 细节(Try source / Token FAIL / do.php code=
// 等)原本只进 WebView 控制台,远程日志里完全看不到,点歌失败无从定位。
// 这里把它转发到 android.log → MiliLog,并保留原始 console 行为。
val consoleBridge = """
(function(){
try {
var orig = console.log;
console.log = function(){
try { android.log(Array.prototype.map.call(arguments, String).join(' ')); } catch(e) {}
if (orig) { try { orig.apply(console, arguments); } catch(e) {} }
};
} catch(e) {}
})();
""".trimIndent()
return "<!doctype html><html><head><meta charset=\"utf-8\"></head><body><script>$consoleBridge</script>" +
"<script>$escaped</script></body></html>"
}
inner class NativeBridge {
@JavascriptInterface
fun log(message: String) = Log.d(TAG, message)
fun log(message: String) {
Log.d(TAG, message)
// 把 JS 侧日志接进远程日志:歌曲源解析(token/do.php/多源排序)的失败原因
// 以前只走 console.log,现场完全看不到。加上前缀便于检索与过滤。
runCatching { MiliLog.info("JS ${message.take(180)}") }
}
@JavascriptInterface
fun onUrl(requestId: String, url: String) {
@@ -318,7 +413,8 @@ class KtvJsBridge(context: Context) {
}
}
private fun request(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String {
/** 供 [rawCleanGet] 与 [NativeBridge.httpGet]/[httpPost] 共用的请求入口。 */
internal fun request(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String {
// 歌曲源(如 w.w345.my)的 do.php 风控只放行"干净"请求(原版仅 Host+Accept;
// 带任何 UA/Connection/Accept-Encoding 头都会被拒)。裸 Socket 只发 Host+JS 头。
// 任何 HTTP 状态(200/403 等)都直接用裸 Socket 结果,不再用 HttpURLConnection
@@ -392,10 +488,22 @@ class KtvJsBridge(context: Context) {
val isHttps = url.startsWith("https://")
val uri = URL(url).toURI()
val port = if (uri.port > 0) uri.port else if (isHttps) 443 else 80
val connectTimeout = timeoutMs.coerceIn(1_000, 30_000)
socket = if (isHttps) {
(SSLSocketFactory.getDefault().createSocket(uri.host, port) as SSLSocket).apply { startHandshake() }
// 用 TlsCompat 的工厂(系统信任 ∪ 内置根证书):裸 socket 走的是
// SSLSocketFactory.getDefault(),不受 HttpsURLConnection 的默认工厂影响,
// network_security_config 也管不到它 —— 老设备上会因缺 ISRG 新根握手失败。
(TlsCompat.sslSocketFactory().createSocket() as SSLSocket).apply {
connect(java.net.InetSocketAddress(uri.host, port), connectTimeout)
startHandshake()
}
} else {
Socket(uri.host, port)
// ⚠️ 必须显式给连接超时:原写法 `Socket(host, port)` 的连接阶段**不受**
// soTimeout 约束,遇到路由黑洞会按系统 TCP 超时(约 2 分钟)干等,
// 多个源叠加会把 JS 的 3 分钟预算耗光,表现为「无法生成下载地址」。
Socket().apply {
connect(java.net.InetSocketAddress(uri.host, port), connectTimeout)
}
}
socket.soTimeout = timeoutMs.coerceIn(1_000, 30_000)
val path = if (uri.rawQuery != null) "${uri.rawPath}?${uri.rawQuery}" else uri.rawPath
@@ -436,6 +544,11 @@ class KtvJsBridge(context: Context) {
JSONObject().put("status", status).put("body", responseBody).toString()
} catch (error: Throwable) {
Log.w(TAG, "raw request failed: ${error.message}")
// 裸 Socket 失败原因以前只在 logcat:点歌失败时远程日志里什么都看不到。
MiliLog.warn(
"RAW-ERR $method ${runCatching { java.net.URI(url).host }.getOrNull() ?: url} " +
"${error.javaClass.simpleName}: ${error.message}",
)
JSONObject().put("status", 0).put("body", "").put("error", error.message.orEmpty()).toString()
} finally {
try { socket?.close() } catch (ignored: Exception) {}
@@ -475,7 +588,8 @@ class KtvJsBridge(context: Context) {
out.write(readExact(input, size.toLong()))
readAsciiLine(input) // 吃掉 chunk 尾部 CRLF
}
return out.toString(Charsets.UTF_8)
// ByteArrayOutputStream.toString(Charset) 是 API 33+;低版本用 String(bytes, charset)。
return String(out.toByteArray(), Charsets.UTF_8)
}
}
@@ -0,0 +1,121 @@
package com.local.ktv
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
/**
* 设备网络状态监测(断网 / 恢复),供「网络未连接」提示与断网重连后的会话重建使用。
*
* 只依赖 `ConnectivityManager` 的实时回调,不轮询:
* - `onAvailable` / `onLost` / `onCapabilitiesChanged` 维护「缺省网络是否具备互联网能力」;
* - 状态**变化**时回调上层(已切到主线程),由上层处理提示与恢复动作。
*
* 注意区分两件事:
* - 这里判断的是**系统级网络可用性**(有没有网);
* - 歌曲源的「试用窗口 / 风控」是按**出口 IP** 计的(见 [Eac19WindowGuard]),
* 换网/重连后即使有网也可能需要重新建立会话 —— 这由上层在恢复回调里处理。
*
* 全局可读 [online],便于下载层判断失败是否由断网引起。
*/
object KtvNetworkMonitor {
private const val TAG = "KtvNetworkMonitor"
/** 当前是否有可用网络(默认 true,避免启动瞬间误报断网)。 */
@Volatile
var online: Boolean = true
private set
private val mainHandler = Handler(Looper.getMainLooper())
private var manager: ConnectivityManager? = null
private var callback: ConnectivityManager.NetworkCallback? = null
/** 开始监测;[onChange] 只在状态真正变化时回调(主线程)。 */
fun start(context: Context, onChange: (online: Boolean) -> Unit) {
if (callback != null) return
val cm = context.applicationContext
.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
manager = cm
val initial = currentOnline(cm)
online = initial
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
publish(true, onChange)
}
override fun onLost(network: Network) {
// 掉了一个网络不代表彻底没网(可能还有以太网/其它网络),重新判定一次。
publish(currentOnline(manager), onChange)
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
publish(caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET), onChange)
}
}
callback = cb
val registered = runCatching {
cm?.registerNetworkCallback(
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build(),
cb,
)
true
}.getOrElse {
Log.w(TAG, "registerNetworkCallback failed: ${it.message}")
false
}
if (!registered) {
callback = null
manager = null
}
Log.i(TAG, "monitor started, online=$initial registered=$registered")
onChange(initial)
}
fun stop() {
val cb = callback ?: return
runCatching { manager?.unregisterNetworkCallback(cb) }
.onFailure { Log.w(TAG, "unregister failed: ${it.message}") }
callback = null
manager = null
Log.i(TAG, "monitor stopped")
}
private fun publish(value: Boolean, onChange: (Boolean) -> Unit) {
mainHandler.post {
if (value == online) return@post
online = value
Log.i(TAG, "network state changed: online=$value")
onChange(value)
}
}
/** 主动复检一次(例如从后台回到前台),有变化才回调。 */
fun refresh(onChange: (online: Boolean) -> Unit) {
publish(currentOnline(manager), onChange)
}
private fun currentOnline(cm: ConnectivityManager?): Boolean {
val manager = cm ?: return true
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val network = manager.activeNetwork ?: return false
val caps = manager.getNetworkCapabilities(network) ?: return false
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} else {
@Suppress("DEPRECATION")
manager.activeNetworkInfo?.isConnected == true
}
} catch (e: Throwable) {
Log.w(TAG, "currentOnline failed: ${e.message}")
true
}
}
}
@@ -47,6 +47,12 @@ class KtvStore {
@JvmField var tableBroadcastEnabled = true
@JvmField var tableBroadcastSeconds = 8
@JvmField var tableBroadcastText = "欢迎光临"
// ===== 均衡器(KtvEqualizer)=====
@JvmField var eqEnabled = false
@JvmField var eqPreset = "自定义"
/** 各频段增益(dB),逗号分隔;不同设备频段数可能不同,加载后会按实际频段数对齐。 */
@JvmField var eqLevels = ""
@JvmField var eqGlobalMix = false
@JvmField val orderQueue = mutableListOf<Song>()
@JvmField val sangHistory = mutableListOf<Song>()
@JvmField val scoreHistory = mutableListOf<String>()
@@ -96,6 +102,10 @@ class KtvStore {
tableBroadcastEnabled = root.optBoolean("tableBroadcastEnabled", true)
tableBroadcastSeconds = root.optInt("tableBroadcastSeconds", 8)
tableBroadcastText = root.optString("tableBroadcastText", "欢迎光临")
eqEnabled = root.optBoolean("eqEnabled", false)
eqPreset = root.optString("eqPreset", "自定义")
eqLevels = root.optString("eqLevels", "")
eqGlobalMix = root.optBoolean("eqGlobalMix", false)
readSongs(root.optJSONArray("orderQueue"), orderQueue)
readSongs(root.optJSONArray("sangHistory"), sangHistory)
root.optJSONArray("scoreHistory").copyStringsTo(scoreHistory)
@@ -164,6 +174,10 @@ class KtvStore {
put("tableBroadcastEnabled", tableBroadcastEnabled)
put("tableBroadcastSeconds", tableBroadcastSeconds)
put("tableBroadcastText", tableBroadcastText)
put("eqEnabled", eqEnabled)
put("eqPreset", eqPreset)
put("eqLevels", eqLevels)
put("eqGlobalMix", eqGlobalMix)
put("orderQueue", writeSongs(orderQueue))
put("sangHistory", writeSongs(sangHistory))
put("scoreHistory", JSONArray(scoreHistory.take(100)))
File diff suppressed because it is too large Load Diff
@@ -584,11 +584,14 @@ class MuseDatabase @JvmOverloads constructor(initialSourceFile: File? = null) {
companion object {
private const val TAG = "MuseDatabase"
private const val DEFAULT_CDN_PATH = "https://pub.mcdn.cherryonline.cn/"
private const val DEFAULT_DB_PATH = "${AppPaths.ROOT_PATH}/database/muse.db"
const val PAGE_SIZE = 10
/** 搜索结果条数统计上限:达到该值即视为“足够多”,终止 COUNT 扫描以加速检索。 */
const val SEARCH_COUNT_CAP = 2000
const val VIDEO_ROOT = AppPaths.VIDEO_PATH
/**
* 视频根目录。**必须是运行时取值**:存储根由 [AppPaths.init] 在启动时解析,
* 不能再像以前那样用 `const val` 把 `/storage/emulated/0/...` 编译期内联进去。
*/
val VIDEO_ROOT: String get() = AppPaths.videoDir.absolutePath
const val CLOUD_SONG_DIR = "cloud-song"
const val LOCAL_SONG_DIR = "local"
@@ -623,10 +626,10 @@ class MuseDatabase @JvmOverloads constructor(initialSourceFile: File? = null) {
fun songDirectories(): List<File> = listOf(AppPaths.cloudSongsDir)
@JvmStatic
fun defaultDbFile(): File = File(DEFAULT_DB_PATH)
fun defaultDbFile(): File = AppPaths.databaseFile
private fun findDatabaseFile(): File? {
return File(DEFAULT_DB_PATH).takeIf { it.exists() && it.canRead() }
return AppPaths.databaseFile.takeIf { it.exists() && it.canRead() }
}
private fun pageArgs(limit: Int, offset: Int) = arrayOf(limit.toString(), offset.toString())
+59 -12
View File
@@ -50,8 +50,12 @@ object MuseDbSync {
file.version
}.getOrNull()
/** 下载并安装曲库。失败抛异常,由调用方决定是否回退到其它源。 */
fun download(onProgress: (Int) -> Unit): File {
/**
* 下载并安装曲库。
* @param onProgress (百分比, 明细文案);明细非空时优先显示(如「正在解压曲库 512MB」),
* 因为解压/校验阶段很慢,只给百分比会让界面长时间不动、看着像卡死。
*/
fun download(onProgress: (Int, String?) -> Unit): File {
val cookie = login() ?: error("登录曲库服务失败")
val file = sync(cookie) ?: error("曲库同步失败或无可用文件")
val version = file.version
@@ -59,23 +63,32 @@ object MuseDbSync {
val gzFile = File(target.parentFile, "${target.name}.gz.download")
val tmpDb = File(target.parentFile, "${target.name}.download")
try {
downloadFile(file.url, gzFile) { pct -> onProgress((pct * 80 / 100).coerceIn(0, 80)) }
onProgress(80)
downloadFile(file.url, gzFile) { pct -> onProgress((pct * 80 / 100).coerceIn(0, 80), null) }
onProgress(80, "正在校验曲库文件")
if (file.md5.isNotEmpty()) {
check(md5(gzFile).equals(file.md5, ignoreCase = true)) { "曲库文件校验失败" }
}
onProgress(84)
onProgress(84, "正在解压曲库 0MB")
GZIPInputStream(gzFile.inputStream().buffered(256 * 1024)).use { gz ->
BufferedOutputStream(FileOutputStream(tmpDb)).use { out ->
val buffer = ByteArray(256 * 1024)
var written = 0L
var lastStep = -1
while (true) {
val count = gz.read(buffer)
if (count < 0) break
out.write(buffer, 0, count)
written += count
// 解压约 1GB、在该设备上要几分钟:每 64MB 报一次,界面才不会像是卡死。
val step = (written / (64L * 1024 * 1024)).toInt()
if (step != lastStep) {
lastStep = step
onProgress(84, "正在解压曲库 ${written / 1048576}MB")
}
}
}
}
onProgress(96)
onProgress(96, "正在安装曲库")
val backup = File(target.parentFile, "${target.name}.backup")
backup.delete()
if (target.exists()) check(target.renameTo(backup)) { "无法备份旧曲库" }
@@ -85,10 +98,14 @@ object MuseDbSync {
}
backup.delete()
saveVersion(version)
onProgress(100)
onProgress(100, "曲库就绪")
return target
} finally {
gzFile.delete()
// 失败时两个临时文件都要清掉:残留的 .gz.download(430MB) + .download(965MB)
// 白占约 1.4GB,反复重试时正是"空间不足"的常见来源。
// 成功路径上 tmpDb 已 rename 成正式库,这里的 delete 是空操作。
runCatching { gzFile.delete() }
runCatching { tmpDb.delete() }
}
}
@@ -143,13 +160,18 @@ object MuseDbSync {
setRequestProperty("User-Agent", "MaidongKTV/1.2")
}
conn.outputStream.use { it.write(jsonBody.toByteArray(Charsets.UTF_8)) }
if (conn.responseCode !in 200..299) return null
if (conn.responseCode !in 200..299) {
MiliLog.warn("MUSE-DB login HTTP ${conn.responseCode} (url=$url)")
return null
}
conn.inputStream.use { it.readBytes() }
conn.getHeaderField("Set-Cookie")
?.substringBefore(';')
?.takeIf { it.startsWith("muse-portal-connector=") }
} catch (error: Throwable) {
Log.w(TAG, "login failed: ${error.message}")
// 老设备上最常见的就是证书链校验失败(SSLHandshakeException),必须上报。
MiliLog.warn("MUSE-DB login error: ${error.javaClass.simpleName}: ${error.message}")
null
} finally {
conn?.disconnect()
@@ -171,11 +193,15 @@ object MuseDbSync {
setRequestProperty("User-Agent", "MaidongKTV/1.2")
}
conn.outputStream.use { it.write(jsonBody.toByteArray(Charsets.UTF_8)) }
if (conn.responseCode !in 200..299) return null
if (conn.responseCode !in 200..299) {
MiliLog.warn("MUSE-DB sync HTTP ${conn.responseCode} (url=$url)")
return null
}
val text = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
JSONObject(text)
} catch (error: Throwable) {
Log.w(TAG, "sync failed: ${error.message}")
MiliLog.warn("MUSE-DB sync error: ${error.javaClass.simpleName}: ${error.message}")
null
} finally {
conn?.disconnect()
@@ -194,7 +220,9 @@ object MuseDbSync {
}
val code = conn.responseCode
check(code in 200..299) { "HTTP $code" }
val total = conn.contentLengthLong
val total = conn.contentLengthCompat()
val startedAt = System.currentTimeMillis()
var lastPercent = -1
conn.inputStream.buffered(256 * 1024).use { input ->
dest.outputStream().buffered(256 * 1024).use { out ->
val buffer = ByteArray(256 * 1024)
@@ -204,7 +232,26 @@ object MuseDbSync {
if (count < 0) break
out.write(buffer, 0, count)
read += count
if (total > 0) onProgress(((read * 100) / total).toInt().coerceIn(0, 99))
if (total > 0) {
val percent = ((read * 100) / total).toInt().coerceIn(0, 99)
// ⚠️ 必须按百分比去重:430MB 会读约 1700 个 256KB 分块,
// 每块都回调一次会把主线程 Looper 队列压满(界面卡死、整机 CPU 被
// 布局任务吃掉,下载本身也跟着变慢)。
if (percent != lastPercent) {
lastPercent = percent
onProgress(percent)
if (percent % 10 == 0) {
val seconds = ((System.currentTimeMillis() - startedAt) / 1000.0)
.coerceAtLeast(0.001)
val mbps = read / 1048576.0 / seconds
MiliLog.info(
"MUSE-DB progress $percent% " +
"${read / 1048576}MB/${total / 1048576}MB " +
"${String.format(Locale.ROOT, "%.2f", mbps)}MB/s",
)
}
}
}
}
}
}
@@ -0,0 +1,153 @@
package com.local.ktv
/**
* 公放随机清单(伪随机)。
*
* 原来的「随机续播」是每首播完现随机挑一首,容易在短时间内重复播到同一首歌。
* 这里改成一次性生成一份**打乱顺序的临时清单**,按顺序往下播:
* - 一轮之内**不重复**播放同一首歌([playedIds] 记录本轮已播);
* - 每首**播放完毕 +1**([playCounts] 计数,同时用于观测与诊断);
* - 每次取下一首都会重新比对曲库,**自动吸纳新增的本地歌曲**([refresh]),
* 被删除的歌自动从清单里剔除;
* - 一轮播完后自动开启新一轮(重新打乱),并尽量不把刚播完的那首放在开头。
*
* 三条硬性约束(与需求一致):
* 1. **不显示**:清单只在内存中,界面上不展示;
* 2. **不关联点歌清单**:与 `MainActivity.orderQueue` 完全独立,随机歌曲不写入已点列表,
* 它是纯粹的「公放清单」;
* 3. **App 退出即清理**:纯内存态,随进程结束消失;退出路径也会主动调用 [clear]。
*/
object RandomPlayQueue {
private val pending = ArrayDeque<Song>()
/** 本轮已播(用于去重)。 */
private val playedIds = LinkedHashSet<String>()
/** 每首播放完毕的次数(+1)。 */
private val playCounts = HashMap<String, Int>()
/** 曾经入过清单的曲目 id,用于识别「新增歌曲」。 */
private val knownIds = LinkedHashSet<String>()
/** 最近一首由本清单开始播放的曲目 id(用于判断播完的是不是公放清单里的歌)。 */
@Volatile private var lastStartedId: String = ""
private val random = java.util.Random()
/** 当前是第几轮(从 1 开始)。 */
var cycle: Int = 0
private set
/** 清单里还剩几首。 */
val remaining: Int get() = pending.size
/** 本轮已播几首。 */
val playedInCycle: Int get() = playedIds.size
/** 清空清单(App 退出时调用)。 */
fun clear() {
pending.clear()
playedIds.clear()
playCounts.clear()
knownIds.clear()
lastStartedId = ""
cycle = 0
}
/** 某首在本会话内播放完毕的次数。 */
fun playCount(id: String): Int = playCounts[id] ?: 0
/** 这首是不是当前正在播的公放清单曲目。 */
fun isCurrentTrack(song: Song): Boolean = KtvStore.stableId(song) == lastStartedId
/** 开始播放某首随机曲目时登记(立即计入本轮已播,保证被打断也不会重复)。 */
fun markStarted(song: Song) {
val id = KtvStore.stableId(song)
lastStartedId = id
playedIds.add(id)
}
/** 某首随机曲目播放完毕:计数 +1。 */
fun markFinished(song: Song): Int {
val id = KtvStore.stableId(song)
lastStartedId = ""
val next = (playCounts[id] ?: 0) + 1
playCounts[id] = next
return next
}
/**
* 取下一首。
*
* @param candidates 当前可播的本地曲目(每次实时传入,从而自动吸纳新增歌曲)
* @param avoidId 尽量避开的曲目 id(通常是刚刚播完的那首)
* @return 下一首;曲库为空时返回 null
*/
fun next(candidates: List<Song>, avoidId: String? = null): Song? {
refresh(candidates)
val available = LinkedHashMap<String, Song>()
candidates.forEach { available[KtvStore.stableId(it)] = it }
// 取清单里还没播过的第一首。
while (pending.isNotEmpty()) {
val song = pending.removeFirst()
val id = KtvStore.stableId(song)
if (available.containsKey(id) && !playedIds.contains(id)) return song
}
// 清单取空(本轮全部播完 / 都被删除)→ 开启新一轮。
if (available.isEmpty()) return null
startNewCycle(available, avoidId)
while (pending.isNotEmpty()) {
val song = pending.removeFirst()
val id = KtvStore.stableId(song)
if (available.containsKey(id) && !playedIds.contains(id)) return song
}
// 极端兜底:曲库只有一首且被要求避开时仍要能播。
return available.values.firstOrNull()
}
/**
* 与当前曲库对齐:剔除已不存在的曲目,并把新出现的曲目随机插入清单。
*/
private fun refresh(candidates: List<Song>) {
val currentIds = candidates.map { KtvStore.stableId(it) }.toSet()
if (knownIds.isEmpty() && pending.isEmpty()) {
// 第一次使用:整库打乱作为第一轮清单。
startNewCycle(candidates.associateBy { KtvStore.stableId(it) }, null)
return
}
// 1) 剔除曲库里已不存在的曲目。
pending.removeAll { !currentIds.contains(KtvStore.stableId(it)) }
playedIds.removeAll { !currentIds.contains(it) }
knownIds.retainAll(currentIds)
// 2) 新增歌曲:随机插入清单,避免总是排在最后。
val added = candidates.filter { knownIds.add(KtvStore.stableId(it)) }
if (added.isEmpty()) return
added.shuffled(random).forEach { song ->
val index = if (pending.isEmpty()) 0 else random.nextInt(pending.size + 1)
pending.add(index, song)
}
}
/** 开启新一轮:重新打乱整库,尽量把 [avoidId] 放到最后。 */
private fun startNewCycle(candidates: Map<String, Song>, avoidId: String?) {
cycle++
playedIds.clear()
pending.clear()
val shuffled = candidates.values.shuffled(random).toMutableList()
if (avoidId != null && shuffled.size > 1) {
val head = shuffled.firstOrNull { KtvStore.stableId(it) == avoidId }
if (head != null) {
shuffled.remove(head)
shuffled.add(head)
}
}
pending.addAll(shuffled)
knownIds.clear()
knownIds.addAll(candidates.keys)
}
}
@@ -39,7 +39,9 @@ object SingerAvatarLoader {
return
}
val waiters = waiting.computeIfAbsent(key) { CopyOnWriteArrayList() }
// ConcurrentHashMap.computeIfAbsent 是 API 24+;putIfAbsent 自 API 1 起可用,语义等价。
val created = CopyOnWriteArrayList<WeakReference<ImageView>>()
val waiters = waiting.putIfAbsent(key, created) ?: created
waiters += WeakReference(view)
if (waiters.size > 1) return
val cacheDir = File(view.context.applicationContext.cacheDir, "singer_avatars").apply { mkdirs() }
@@ -70,7 +72,7 @@ object SingerAvatarLoader {
connection.instanceFollowRedirects = true
connection.setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}")
require(connection.responseCode in 200..299)
require(connection.contentLengthLong <= 0 || connection.contentLengthLong <= MAX_DOWNLOAD_BYTES)
require(connection.contentLengthCompat() <= 0 || connection.contentLengthCompat() <= MAX_DOWNLOAD_BYTES)
var total = 0L
connection.inputStream.buffered().use { input ->
FileOutputStream(temporary).buffered().use { output ->
@@ -24,9 +24,13 @@ object SongApiClient {
fun init(context: Context) {
if (bridge != null) return
bridge = KtvJsBridge(context)
// 原生兜底取址通道:复用桥的「干净请求」裸 Socket(与 JS 同一条路,风控表现一致),
// 但**不依赖 WebView 是否可用** —— 老盒子上 WebView 不可用时唯一能取到地址的路径。
SongUrlResolver.install { url -> bridge?.rawCleanGet(url) }
bridge?.init { ok ->
ready = ok
Log.i(TAG, "JS Bridge init: $ok")
if (!ok) MiliLog.error("JS-BRIDGE 初始化返回 false,取址将走原生兜底")
}
}
@@ -47,10 +51,10 @@ object SongApiClient {
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? {
// 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。
if (Looper.myLooper() == Looper.getMainLooper()) {
Log.w(TAG, "Synchronous JS request on main thread; using DB fallback")
return fetchFromDb(musicno)
Log.w(TAG, "Synchronous JS request on main thread; using native/DB fallback")
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
}
val bridge = bridge ?: return fetchFromDb(musicno)
val bridge = bridge ?: return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
// 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备
// (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。
// 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。
@@ -62,8 +66,11 @@ object SongApiClient {
}
}
if (!bridge.isReady()) {
Log.w(TAG, "Bridge not ready after wait, DB fallback")
return fetchFromDb(musicno)
// ⚠️ 老盒子(WebView 不可用)会一直走到这里。以前直接回退 DB,结果就是
// 「无法生成下载地址」且远程日志里连一条 JS 日志都没有。现在改走原生解析器。
Log.w(TAG, "Bridge not ready after wait, native resolver")
MiliLog.warn("JS-BRIDGE 15s 未就绪,改用原生取址通道")
return resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
}
val result = AtomicReference<String?>()
val latch = CountDownLatch(1)
@@ -74,7 +81,9 @@ object SongApiClient {
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
val url = result.get() ?: fetchFromDb(musicno)
// 桥可用但 JS 没取到地址(或超时)时,同样再给原生解析器一次机会,
// 避免 JS 侧偶发失败直接把第一首歌判死。
val url = result.get() ?: resolveNatively(musicno, resolution, h265) ?: fetchFromDb(musicno)
if (url != null) {
// 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。
Eac19WindowGuard.noteUrlOk()
@@ -82,9 +91,17 @@ object SongApiClient {
return url
}
/**
* 原生兜底取址:WebView 不可用(老盒子)或 JS 没取到地址时使用。
* 走的是与 JS 完全相同的裸 Socket 干净请求通道,见 [SongUrlResolver]。
*/
private fun resolveNatively(musicno: String, resolution: String, h265: Boolean): String? =
runCatching { SongUrlResolver.resolve(musicno, resolution, h265) }
.onFailure { Log.w(TAG, "native resolver failed", it) }
.getOrNull()
/** DB cloud_url fallback — 必须通过 demo 检测才能使用 */
private fun fetchFromDb(musicno: String): String? {
try {
private fun fetchFromDb(musicno: String): String? { try {
val db = MuseDatabase()
if (db.open()) {
val url = db.getCloudUrl(musicno)
@@ -106,8 +123,22 @@ object SongApiClient {
return false
}
/**
* 重置歌曲源会话(清 JS 侧 token / API 实例)。
*
* 注意:这里原来是**空实现**,导致断网重连后仍复用旧 token —— 现象是
* 「日志提示歌曲源获取成功(地址拿到了),但实际下载失败/403」。
*/
@JvmStatic
fun clearTokenCache() {}
fun clearTokenCache() {
bridge?.resetApiSession()
}
/** 语义更明确的别名:断网恢复后重建歌曲源会话。 */
@JvmStatic
fun resetSourceSession() {
bridge?.resetApiSession()
}
/** 最近一次点歌实际命中的歌曲源 host(由 JS 上报, 用于远程日志)。 */
@JvmStatic
@@ -8,6 +8,8 @@ import com.liulishuo.okdownload.core.cause.EndCause
import com.liulishuo.okdownload.core.cause.ResumeFailedCause
import com.liulishuo.okdownload.core.listener.DownloadListener2
import java.io.File
import java.util.Collections
import java.util.LinkedHashMap
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
@@ -16,6 +18,8 @@ import java.util.concurrent.atomic.AtomicLong
object SongOkDownloadManager {
private const val TAG = "SongDownload"
const val MIN_VALID_FILE_SIZE = SongFileValidator.MIN_VALID_FILE_SIZE
/** 网络恢复后最多自动重试几首(防止一次拉起过多并发下载)。 */
private const val MAX_NETWORK_RETRY = 8
private const val MAX_RETRY_COUNT = 3
private const val STALL_TIMEOUT_MS = 25_000L
@@ -28,12 +32,16 @@ object SongOkDownloadManager {
}
private val tasks = ConcurrentHashMap<String, OkDownloadTask>()
private val pending = ConcurrentHashMap.newKeySet<String>()
// ConcurrentHashMap.newKeySet() 是 API 24+(低版本 NoSuchMethodError);
// Collections.newSetFromMap 自 API 9 起可用,且返回普通 Set,add/remove/contains 均成立于 API 1。
private val pending: MutableSet<String> =
Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
private val callbacks = ConcurrentHashMap<String, DownloadCallback>()
private val progress = ConcurrentHashMap<String, Int>()
private val totalLengths = ConcurrentHashMap<String, Long>()
private val forcedRetryReasons = ConcurrentHashMap<String, String>()
private val manuallyCanceled = ConcurrentHashMap.newKeySet<String>()
private val manuallyCanceled: MutableSet<String> =
Collections.newSetFromMap(ConcurrentHashMap<String, Boolean>())
private val io = Executors.newFixedThreadPool(4)
private val watchdog = Executors.newSingleThreadScheduledExecutor()
@@ -96,6 +104,8 @@ object SongOkDownloadManager {
}
if (url.isEmpty()) {
pending.remove(key)
// 取不到地址通常也是网络/源站不可达, 记下来供网络恢复后自动重试。
if (isNetworkFailure(null, 0)) networkFailed[key] = song
callback?.onDownloadFailed(song, "无法生成下载地址")
} else {
startDownloadTask(song, url, callback, 0)
@@ -113,6 +123,7 @@ object SongOkDownloadManager {
pending.remove(key)
tasks.remove(key)?.let { task ->
manuallyCanceled.add(key)
networkFailed.remove(key)
task.cancel()
clearBrokenDownload(task, getLocalFile(song))
}
@@ -124,6 +135,43 @@ object SongOkDownloadManager {
@JvmStatic
fun getDownloadProgress(song: Song): Int = progress[songKey(song)] ?: 0
/**
* 网络恢复后自动重试「因网络原因失败」的下载(最多 [MAX_NETWORK_RETRY] 首)。
*
* 断网期间的失败大多是连不上/超时/HTTP 0,属于可恢复;恢复后重试一次,避免用户
* 手动重新点歌。手动取消的歌不会进入这个集合。
*
* @return 实际发起重试的数量
*/
@JvmStatic
fun retryNetworkFailed(): Int {
val retry = synchronized(networkFailed) {
networkFailed.values.toList().also { networkFailed.clear() }
}
if (retry.isEmpty()) return 0
val limited = retry.take(MAX_NETWORK_RETRY)
Log.i(TAG, "network recovered, retrying ${limited.size} failed download(s)")
io.execute {
limited.forEach { song -> runCatching { download(song, callbacks[songKey(song)]) } }
}
return limited.size
}
/** 判断一个失败是否属于「网络原因」(断网/超时/连不上/HTTP 0)。 */
private fun isNetworkFailure(realCause: Exception?, httpCode: Int): Boolean {
if (!KtvNetworkMonitor.online) return true
if (httpCode == 0) return true
val cause = realCause ?: return false
return cause is java.net.UnknownHostException ||
cause is java.net.ConnectException ||
cause is java.net.SocketTimeoutException ||
cause is java.net.NoRouteToHostException ||
cause is java.net.SocketException ||
cause is javax.net.ssl.SSLException ||
cause is java.io.EOFException ||
cause is java.io.InterruptedIOException
}
private fun buildDownloadUrl(song: Song, forceRefresh: Boolean = false): String {
if (!forceRefresh) {
song.downloadUrl?.takeIf(String::isNotBlank)?.let { cached ->
@@ -222,6 +270,10 @@ object SongOkDownloadManager {
} else {
pending.remove(key)
progress.remove(key)
if (isNetworkFailure(realCause, httpResponseCode)) {
networkFailed[key] = song
Log.w(TAG, "download failed by network: ${song.title} (${cause.name})")
}
cb?.onDownloadFailed(song, cause.name + (realCause?.message?.let { ": $it" } ?: ""))
}
return
@@ -292,6 +344,7 @@ object SongOkDownloadManager {
}
pending.remove(key)
progress.remove(key)
networkFailed.remove(key)
song.path = target.absolutePath
callback?.onDownloadProgress(song, 100)
callback?.onDownloadComplete(song, target.absolutePath)
@@ -371,6 +424,9 @@ object SongOkDownloadManager {
}
}
/** 因网络原因失败的歌曲(网络恢复后自动重试一次)。 */
private val networkFailed = Collections.synchronizedMap(LinkedHashMap<String, Song>())
private fun songKey(song: Song): String = song.id?.takeIf(String::isNotEmpty)
?: song.filename?.takeIf(String::isNotEmpty)
?: song.title.orEmpty()
@@ -0,0 +1,214 @@
package com.local.ktv
import android.util.Log
import org.json.JSONObject
import java.security.MessageDigest
import java.util.Collections
/**
* 歌曲取址的**原生兜底实现**。
*
* ## 为什么需要它
* 取址原本完全依赖隐藏 WebView 里的 `ktv_api.js`。但在一部分老电视盒子/投影上
* (实测:极米 H1S / GMUI2.0 / Android 5.1.1 / API 22)**WebView 不可用**:
* `KtvJsBridge` 永远 `isReady()==false`,`SongApiClient` 等 15 秒后只能回退到数据库里的
* `cloud_url`,取不到就报「无法生成下载地址」。特征是**远程日志里连一条 `JS ...` 都没有**
* (JS 从没跑起来)。
*
* ## 做法
* 用 Kotlin 复刻 `ktv_api.js` 的同一套协议:
* `GET <host>/i.php`(取 token)→ `GET <host>/music/do.php`(取播放地址),
* 并镜像 JS 的多源排序(失败源沉底)与广告/错号资源校验(`isDemoUrl`)。
*
* ⚠️ 它**必须走与 JS 相同的 HTTP 通道**([KtvJsBridge.rawCleanGet] 的裸 Socket,
* 只发 Host + Accept),否则会被源站风控拒绝(带 UA/Connection 头会被判"异常行为")。
*
* ⚠️ 这里的 APP_ID/APP_KEY/SDK_KEY/HOSTS 是 `ktv_api.js` CONFIG 的镜像。
* 源站配置若变更,JS 可热更新而这里需要发版 —— 因此它**只作为兜底**:
* WebView 可用时仍以 JS 为准(见 [SongApiClient.getSongDownloadUrl])。
*/
object SongUrlResolver {
private const val TAG = "SongUrlResolver"
// ── 以下与 assets/mobile/ktv_api.js 的 CONFIG 保持一致 ──
// 注意 VER/VN 与 KtvJsBridge 注入 JS 时覆盖的值一致(不是 JS 文件里的原始值)。
private const val APP_ID = "d4eeacc6cec3434fbc8c41608a3056a0"
private const val APP_KEY = "024210cba40d4385a93e6c2d3249bfb5"
private const val SDK_KEY = "19042303a8374f67ae3fe1e25c97936f"
private const val VER = "2.0"
private const val VN = "4.1.3.03281430"
private const val TIMEOUT_MS = 8_000
private val DEFAULT_HOSTS = listOf(
"http://w.w345.my",
"http://desktok.top",
"http://mm.kk456.top",
"http://w.w345.my:66",
"http://z.w345.my",
"http://b.sktv.top",
"http://w345.my",
)
/** 运行时源优先级(与 JS 一样:解析失败的源沉到队尾)。 */
private val hostOrder: MutableList<String> = Collections.synchronizedList(DEFAULT_HOSTS.toMutableList())
@Volatile private var fetch: ((String) -> String?)? = null
@Volatile private var sn: String = KtvJsBridge.DEVICE_SN
/**
* 安装取址通道。
* @param fetch 干净的 HTTP GET(应传 [KtvJsBridge.rawCleanGet]);
* 入参 url,返回 `{"status":..,"body":..,"error":..}` 的 JSON 字符串。
*/
fun install(fetch: (String) -> String?) {
this.fetch = fetch
Log.i(TAG, "native song url resolver installed (${DEFAULT_HOSTS.size} hosts)")
}
private fun demote(host: String) {
synchronized(hostOrder) {
val index = hostOrder.indexOf(host)
if (index >= 0 && index < hostOrder.size - 1) {
hostOrder.removeAt(index)
hostOrder.add(host)
}
}
}
private fun promote(host: String) {
synchronized(hostOrder) {
val index = hostOrder.indexOf(host)
if (index > 0) {
hostOrder.removeAt(index)
hostOrder.add(0, host)
}
}
}
/** 依次尝试各源,返回可用的播放地址;全部失败返回 null。 */
fun resolve(musicno: String, resolution: String = "720", h265: Boolean = false): String? {
if (musicno.isBlank()) return null
val channel = fetch ?: run {
MiliLog.warn("NATIVE-SONG 未安装取址通道,跳过原生兜底")
return null
}
val mac = runCatching { DeviceMac.effective() }.getOrDefault(sn)
val startedAt = System.currentTimeMillis()
val hosts = synchronized(hostOrder) { hostOrder.toList() }
var lastDetail = "no-source"
for ((index, host) in hosts.withIndex()) {
val token = runCatching { fetchToken(channel, host, mac) }.getOrElse {
MiliLog.warn("NATIVE-SONG i.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
null
}
if (token.isNullOrEmpty()) {
lastDetail = "token-fail"
demote(host)
continue
}
val url = runCatching { fetchSongUrl(channel, host, mac, musicno, token, resolution, h265) }
.getOrElse {
MiliLog.warn("NATIVE-SONG do.php 异常 源=$host ${it.javaClass.simpleName}: ${it.message}")
null
}
if (!url.isNullOrEmpty() && isValidSongUrl(url, musicno)) {
promote(host)
val elapsed = System.currentTimeMillis() - startedAt
val urlHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "?"
MiliLog.info(
"NATIVE-SONG 取址成功 编号=$musicno 源=$host urlHost=$urlHost " +
"第${index + 1}个源 用时=${elapsed}ms",
)
return url
}
lastDetail = when {
url.isNullOrEmpty() -> "no-url"
else -> "demo-or-mismatch"
}
demote(host)
}
val elapsed = System.currentTimeMillis() - startedAt
MiliLog.error("NATIVE-SONG 全部源失败 编号=$musicno 详情=$lastDetail 用时=${elapsed}ms")
return null
}
// ─── 协议实现 ───────────────────────────────────────
private fun fetchToken(channel: (String) -> String?, host: String, mac: String): String? {
val ts = System.currentTimeMillis() / 1000
val params = "appid=$APP_ID&mac=${mac}_$sn&sn=$sn&time=$ts&ver=$VER&vn=$VN"
val sign = md5(params + APP_KEY)
val json = getJson(channel, "$host/i.php?$params&sign=$sign") ?: return null
if (json.optInt("code") != 200) {
MiliLog.warn("NATIVE-SONG token 失败 源=$host code=${json.optInt("code")} msg=${json.optString("msg")}")
return null
}
return json.optString("token").takeIf { it.isNotEmpty() }
}
private fun fetchSongUrl(
channel: (String) -> String?,
host: String,
mac: String,
musicno: String,
token: String,
resolution: String,
h265: Boolean,
): String? {
val ts = System.currentTimeMillis() / 1000
// 参数顺序、原始 token 中的 "=" 以及签名拼接必须与原 APK 一致(同 ktv_api.js)。
val params = "appid=$APP_ID&device=${mac}_$sn&ish265=${if (h265) "1" else "0"}&ls=1" +
"&musicno=$musicno&resolution=$resolution&sn=$sn&time=$ts&token=$token"
val sign = md5(params + SDK_KEY)
val json = getJson(channel, "$host/music/do.php?$params&sign=$sign") ?: return null
val code = json.optInt("code")
if (code != 200) {
MiliLog.warn("NATIVE-SONG do.php 源=$host code=$code msg=${json.optString("msg")}")
return null
}
return json.optString("data").takeIf { it.isNotEmpty() }
}
/** 解析 request() 的信封 `{status, body, error}`,再把 body 解析为 JSON。 */
private fun getJson(channel: (String) -> String?, url: String): JSONObject? {
val raw = channel(url) ?: return null
val envelope = runCatching { JSONObject(raw) }.getOrNull() ?: return null
val status = envelope.optInt("status")
if (status !in 200..299) {
MiliLog.warn(
"NATIVE-SONG HTTP $status ${runCatching { java.net.URI(url).host }.getOrNull()} " +
"err=${envelope.optString("error").take(80)}",
)
return null
}
val body = envelope.optString("body")
if (body.isBlank()) return null
return runCatching { JSONObject(body) }.getOrElse {
MiliLog.warn("NATIVE-SONG 响应非 JSON: ${body.take(120)}")
null
}
}
/** 镜像 ktv_api.js 的 `isDemoUrl`:广告视频 / 纯 IP / R2 测试桶 / ts 编号与请求不一致。 */
private fun isValidSongUrl(url: String, musicno: String): Boolean {
if (AdVideoFilter.isAdVideo(url)) return false
val lower = url.lowercase()
if (lower.contains("wb66") || lower.contains("demo")) return false
if (IP_URL.containsMatchIn(url)) return false
if (R2_DEMO.containsMatchIn(url)) return false
val matched = TS_NAME.find(url)?.groupValues?.getOrNull(1)
if (matched != null && matched != musicno) return false
return true
}
private fun md5(text: String): String =
MessageDigest.getInstance("MD5").digest(text.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
private val IP_URL = Regex("^https?://\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(?::\\d+)?/", RegexOption.IGNORE_CASE)
private val R2_DEMO = Regex("pub-[a-z0-9]+\\.r2\\.dev/ss/", RegexOption.IGNORE_CASE)
private val TS_NAME = Regex("/(\\d+)\\.ts(?:\\?|$)")
}
@@ -0,0 +1,186 @@
package com.local.ktv
import android.content.Context
import android.util.Log
import java.net.URL
import java.security.KeyStore
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.ConcurrentHashMap
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLException
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* 低版本 Android 的 TLS 信任补丁。
*
* **背景**:`*.cherryonline.cn`(曲库同步 `mconn` / 点歌登录 `mws`)与 `gitea.kasugano.cn`
* 都使用 Let's Encrypt 新链:`leaf ← YR2 ← Root YR ← ISRG Root X1`。
* `res/xml/network_security_config.xml` 里虽然把这三个根证书加了信任锚,但
* **该配置只在 Android 7.0(API 24) 及以上被系统读取**;Android 5.1.1(API 22) 上被完全忽略,
* 系统信任库又没有 ISRG 新根 → `SSLHandshakeException: Trust anchor for certification path not found`
* → 曲库登录失败 → 「不能下载曲库」。这也解释了为什么只有老设备出问题(其它设备都是 API 24+)。
*
* **做法**(只对 API < 24 生效,新系统保持原样):
* 1. [install] 构造「系统默认信任 ∪ 应用内置根证书」的复合 X509TrustManager,装到全局
* `HttpsURLConnection` 默认 SSLSocketFactory 上 —— 覆盖 App 内所有 `HttpsURLConnection`
* (曲库同步、JS 热更新下载、更新检查、头像下载等)。
* **主机名校验保持系统默认,不关闭、不放行任意证书。**
* 2. WebView 的 TLS 信任无法用 SSLContext 覆盖,故提供 [isHostTrustedByOurStore]:
* 用上面增强后的信任库**独立地对同一主机发起一次 TLS 校验**;只有当它校验通过
* (说明证书链本身合法、主机名也匹配,WebView 失败纯粹是因为老系统的根证书缺失)时,
* 才允许 WebView 继续。**不做「见到 SSL 错误就放行」的盲目信任。**
*/
object TlsCompat {
private const val TAG = "TlsCompat"
/** 应用内置的根/中间证书(与 network_security_config.xml 中列出的保持一致)。 */
private val BUNDLED_CERTS = intArrayOf(
R.raw.isrg_root_x1,
R.raw.isrg_root_yr,
R.raw.letsencrypt_yr1,
)
@Volatile private var installed = false
/** 「系统信任 ∪ 内置根证书」的 SSLSocketFactory,供裸 Socket 通道复用。 */
@Volatile private var patchedFactory: SSLSocketFactory? = null
/**
* 给**裸 Socket** 通道用的 SSLSocketFactory。
*
* ⚠️ 必须单独提供:`HttpsURLConnection.setDefaultSSLSocketFactory` **不会**影响
* `SSLSocketFactory.getDefault()`,而 `KtvJsBridge.rawRequest()` 的 HTTPS 分支正是用后者
* (`network_security_config` 也只作用于 HttpsURLConnection / WebView,管不到裸 socket)。
* 未初始化时回退系统默认。
*/
fun sslSocketFactory(): SSLSocketFactory =
patchedFactory ?: (SSLSocketFactory.getDefault() as SSLSocketFactory)
/** 主机 → 是否已被增强信任库校验通过(只缓存肯定结果)。 */
private val trustedHosts = ConcurrentHashMap<String, Boolean>()
/** 是否跑在需要补丁的老系统上(API < 24 才会忽略 network_security_config)。 */
val needsLegacyPatch: Boolean get() = android.os.Build.VERSION.SDK_INT < 24
fun install(context: Context) {
if (installed) return
installed = true
runCatching {
val system = systemTrustManager()
val bundled = bundledTrustManager(context)
if (bundled == null) {
Log.w(TAG, "no bundled certs loaded; keep system trust only")
return
}
val composite = CompositeTrustManager(system, bundled)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf<TrustManager>(composite), null)
val factory = sslContext.socketFactory as SSLSocketFactory
// 裸 Socket 通道始终使用它(系统信任 + 内置根证书,二者取并集)。
patchedFactory = factory
if (android.os.Build.VERSION.SDK_INT >= 24) {
// 新系统由 network_security_config 负责 HttpsURLConnection,无需接管默认工厂。
Log.i(TAG, "API>=24, keep system default for HttpsURLConnection")
return
}
HttpsURLConnection.setDefaultSSLSocketFactory(factory)
Log.i(
TAG,
"legacy TLS trust installed: system(${system?.acceptedIssuers?.size ?: 0} CAs) " +
"+ bundled(${BUNDLED_CERTS.size} certs)",
)
}.onFailure { Log.e(TAG, "install failed", it) }
}
/**
* 用**增强后的信任库**校验 `https://host/` 的证书链。
* 返回 true 仅当 TLS 握手与主机名校验都通过(HttpsURLConnection 的默认主机名校验保持开启)。
*/
fun isHostTrustedByOurStore(host: String): Boolean {
if (host.isBlank()) return false
trustedHosts[host]?.let { return it }
val ok = runCatching {
val conn = (URL("https://$host/").openConnection() as HttpsURLConnection).apply {
connectTimeout = 8000
readTimeout = 8000
requestMethod = "HEAD"
instanceFollowRedirects = false
}
try {
// 任何响应码都说明握手 + 主机名校验通过了。
conn.responseCode
true
} catch (e: SSLException) {
Log.w(TAG, "host $host NOT trusted by our store: ${e.message}")
false
} finally {
runCatching { conn.disconnect() }
}
}.getOrDefault(false)
if (ok) trustedHosts[host] = true
return ok
}
private fun systemTrustManager(): X509TrustManager? = runCatching {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
factory.trustManagers.filterIsInstance<X509TrustManager>().firstOrNull()
}.getOrNull()
private fun bundledTrustManager(context: Context): X509TrustManager? = runCatching {
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) }
val certificateFactory = CertificateFactory.getInstance("X.509")
BUNDLED_CERTS.forEachIndexed { index, resId ->
context.resources.openRawResource(resId).use { input ->
certificateFactory.generateCertificate(input)?.let {
keyStore.setCertificateEntry("ktv-bundled-$index", it)
}
}
}
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(keyStore)
factory.trustManagers.filterIsInstance<X509TrustManager>().firstOrNull()
}.onFailure { Log.e(TAG, "load bundled certs failed", it) }.getOrNull()
/** 系统信任 ∪ 内置根证书:任一通过即放行;都不过则抛出首个错误。 */
private class CompositeTrustManager(
private val system: X509TrustManager?,
private val bundled: X509TrustManager?,
) : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>, authType: String) {
var first: CertificateException? = null
for (manager in listOfNotNull(system, bundled)) {
try {
manager.checkClientTrusted(chain, authType)
return
} catch (e: CertificateException) {
if (first == null) first = e
}
}
throw first ?: CertificateException("no trust manager available")
}
override fun checkServerTrusted(chain: Array<out X509Certificate>, authType: String) {
var first: CertificateException? = null
for (manager in listOfNotNull(system, bundled)) {
try {
manager.checkServerTrusted(chain, authType)
return
} catch (e: CertificateException) {
if (first == null) first = e
}
}
throw first ?: CertificateException("no trust manager available")
}
override fun getAcceptedIssuers(): Array<X509Certificate> =
(system?.acceptedIssuers ?: emptyArray()) + (bundled?.acceptedIssuers ?: emptyArray())
}
}
@@ -58,6 +58,9 @@ class KtvVideoView @JvmOverloads constructor(
fun setTone(step: Int) = engine?.setTone(step) ?: Unit
fun selectAudioTrack(original: Boolean): Boolean = engine?.selectAudioTrack(original) == true
fun audioTrackCount(): Int = engine?.audioTrackCount() ?: 0
/** 当前播放的音频会话 id(供均衡器等音效挂载);未知返回 0。 */
fun audioSessionId(): Int = engine?.audioSessionId() ?: 0
fun selectAudioChannel(channel: Int, volume: Float) = engine?.selectAudioChannel(channel, volume) ?: Unit
val isPlaying: Boolean get() = engine?.isPlaying == true
val currentPosition: Int get() = engine?.currentPosition ?: 0
@@ -290,6 +293,12 @@ class KtvPlaybackEngine(context: Context) {
ijkPlayer?.trackInfo?.count { it.trackType == ITrackInfo.MEDIA_TRACK_TYPE_AUDIO } ?: 0
}.getOrDefault(0)
/**
* IJK 原生返回的真实 AudioTrack 会话 id(`IjkMediaPlayer_getAudioSessionId`)。
* 音频均衡器需要它才能把音效挂到本播放器上;播放器尚未建立音轨时返回 0。
*/
fun audioSessionId(): Int = runCatching { ijkPlayer?.audioSessionId ?: 0 }.getOrDefault(0)
fun selectAudioChannel(channel: Int, volume: Float) {
val safeVolume = volume.coerceIn(0f, 1f)
val ijk = ijkPlayer
@@ -1,244 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
调音/音频控制弹窗(对应"弹窗-调音.jpg")
包含:
- 4 个开关按钮:立体声/伴唱/原唱/静音
- 2 个滑块:麦克风(音量+效果)、音乐音量
- 关闭按钮
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="640dp"
android:layout_height="wrap_content"
android:background="@drawable/bg_dialog_panel"
android:orientation="vertical"
android:padding="20dp">
<!-- 标题 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="36dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:layout_marginBottom="14dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="🎚 调音设置"
android:textColor="#FF27CCA4"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/btn_audio_close"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@drawable/bg_btn_dialog_close"
android:gravity="center"
android:text="×"
android:textColor="#FFCCCCCC"
android:textSize="22sp" />
</LinearLayout>
<!-- 4 个开关按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="20dp"
android:weightSum="4">
<TextView
android:id="@+id/btn_tone_stereo"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_toggle_on"
android:gravity="center"
android:text="🔊 立体声"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
<TextView
android:id="@+id/btn_tone_accomp"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_toggle_off"
android:gravity="center"
android:text="🎤 伴唱"
android:textColor="#FFCCCCCC"
android:textSize="14sp" />
<TextView
android:id="@+id/btn_tone_origin"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_toggle_on"
android:gravity="center"
android:text="🎙 原唱"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
<TextView
android:id="@+id/btn_tone_mute"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_toggle_off"
android:gravity="center"
android:text="🔇 静音"
android:textColor="#FFCCCCCC"
android:textSize="14sp" />
</LinearLayout>
<!-- 麦克风(增益/效果由功放混音器硬件调节, 滑杆仅作指示) -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
android:text="麦克风音量(功放/混音器硬件调节)"
android:textColor="#FFE0E0FF"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="14dp">
<SeekBar
android:id="@+id/seek_mic_volume"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="100"
android:progress="60"
android:progressTint="#FF27CCA4"
android:thumbTint="#FF27CCA4"
android:enabled="false" />
<TextView
android:id="@+id/text_mic_volume_value"
android:layout_width="50dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="60"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
android:text="麦克风效果(功放/混音器硬件调节)"
android:textColor="#FFE0E0FF"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="20dp">
<SeekBar
android:id="@+id/seek_mic_effect"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="100"
android:progress="50"
android:progressTint="#FF27CCA4"
android:thumbTint="#FF27CCA4"
android:enabled="false" />
<TextView
android:id="@+id/text_mic_effect_value"
android:layout_width="50dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="50"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
</LinearLayout>
<!-- 音乐音量 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="音乐音量"
android:textColor="#FFE0E0FF"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="20dp">
<SeekBar
android:id="@+id/seek_music_volume"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="100"
android:progress="80"
android:progressTint="#FF27CCA4"
android:thumbTint="#FF27CCA4" />
<TextView
android:id="@+id/text_music_volume_value"
android:layout_width="50dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="80"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
</LinearLayout>
<!-- 底部按钮行 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/btn_audio_default"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:background="@drawable/bg_btn_glass"
android:gravity="center"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:text="恢复默认"
android:textColor="#FFCCCCCC"
android:textSize="13sp" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<TextView
android:id="@+id/btn_audio_save"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:background="@drawable/bg_btn_save"
android:gravity="center"
android:paddingStart="28dp"
android:paddingEnd="28dp"
android:text="保存"
android:textColor="#FFFFFFFF"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
-104
View File
@@ -1,104 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="470dp"
android:layout_height="340dp"
android:background="@drawable/bg_tuning_dialog"
android:orientation="vertical"
android:paddingStart="28dp"
android:paddingTop="20dp"
android:paddingEnd="28dp"
android:paddingBottom="22dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="44dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="调音"
android:textColor="#FFFFFFFF"
android:textSize="24sp" />
<TextView
android:id="@+id/btn_tuning_close"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="end|center_vertical"
android:gravity="center"
android:text="×"
android:textColor="#99FFFFFF"
android:textSize="22sp" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="116dp"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_marginEnd="12dp"
android:background="@drawable/bg_tuning_counter"
android:gravity="center"
android:orientation="vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:text="升降调" android:textColor="#BFFFFFFF" android:textSize="17sp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="54dp"
android:gravity="center" android:orientation="horizontal">
<TextView android:id="@+id/btn_tone_down" android:layout_width="48dp" android:layout_height="48dp"
android:gravity="center" android:text="−" android:textColor="#BFFFFFFF" android:textSize="34sp" />
<TextView android:id="@+id/text_tone_counter" android:layout_width="56dp" android:layout_height="48dp"
android:gravity="center" android:text="0" android:textColor="#BFFFFFFF" android:textSize="20sp" />
<TextView android:id="@+id/btn_tone_up" android:layout_width="48dp" android:layout_height="48dp"
android:gravity="center" android:text="+" android:textColor="#BFFFFFFF" android:textSize="30sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_tuning_counter"
android:gravity="center"
android:orientation="vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:text="麦克风" android:textColor="#FFFFFFFF" android:textSize="17sp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="54dp"
android:gravity="center" android:orientation="horizontal">
<TextView android:id="@+id/btn_mic_down" android:layout_width="48dp" android:layout_height="48dp"
android:gravity="center" android:text="−" android:textColor="#FFFFFFFF" android:textSize="34sp" />
<TextView android:id="@+id/text_mic_counter" android:layout_width="56dp" android:layout_height="48dp"
android:gravity="center" android:text="50" android:textColor="#FFFFFFFF" android:textSize="20sp" />
<TextView android:id="@+id/btn_mic_up" android:layout_width="48dp" android:layout_height="48dp"
android:gravity="center" android:text="+" android:textColor="#FFFFFFFF" android:textSize="30sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal"
android:weightSum="4">
<TextView android:id="@+id/btn_preset_pop" android:layout_width="0dp" android:layout_height="92dp"
android:layout_weight="1" android:gravity="center" android:text="♬\n流行" android:textColor="#FFFFFFFF" android:textSize="16sp" />
<TextView android:id="@+id/btn_preset_lyric" android:layout_width="0dp" android:layout_height="92dp"
android:layout_weight="1" android:gravity="center" android:text="▥\n抒情" android:textColor="#FFFFFFFF" android:textSize="16sp" />
<TextView android:id="@+id/btn_preset_rock" android:layout_width="0dp" android:layout_height="92dp"
android:layout_weight="1" android:gravity="center" android:text="♨\n摇滚" android:textColor="#FFFFFFFF" android:textSize="16sp" />
<TextView android:id="@+id/btn_preset_singer" android:layout_width="0dp" android:layout_height="92dp"
android:layout_weight="1" android:gravity="center" android:text="♪\n唱将" android:textColor="#FFFFFFFF" android:textSize="16sp" />
</LinearLayout>
</LinearLayout>