Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7a28fc45d | ||
|
|
1687f87eca | ||
|
|
691e56f617 | ||
|
|
0cffd08789 | ||
|
|
1ceba8ef7f | ||
|
|
a467719675 | ||
|
|
62216b0fb8 | ||
|
|
8ad2aa50d7 | ||
|
|
40d1fec719 | ||
|
|
96c83d27b0 | ||
|
|
08b5b438b9 | ||
|
|
7803d34a42 | ||
|
|
942ec527f1 | ||
|
|
33b704bd5a | ||
|
|
42919cc8e3 | ||
|
|
7e3d233891 | ||
|
|
0c7aa46458 | ||
|
|
ce57068c68 | ||
|
|
dcc2acb719 | ||
|
|
4c22826ea1 |
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user