Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08b5b438b9 | ||
|
|
7803d34a42 | ||
|
|
942ec527f1 | ||
|
|
33b704bd5a | ||
|
|
42919cc8e3 | ||
|
|
7e3d233891 | ||
|
|
0c7aa46458 | ||
|
|
ce57068c68 | ||
|
|
dcc2acb719 | ||
|
|
4c22826ea1 | ||
|
|
4eb6b7b382 | ||
|
|
0549f910a6 | ||
|
|
221516f319 | ||
|
|
485235e96e | ||
|
|
2a3c4e6003 | ||
|
|
914450bc6e | ||
|
|
2779237408 | ||
|
|
b3b8e6a12d | ||
|
|
b37dbec443 | ||
|
|
4c792361a4 |
@@ -2,29 +2,54 @@
|
||||
|
||||
> 本文件供后续开发(含 AI 助手会话)遵循,优先级高于默认习惯。
|
||||
|
||||
## 一、代码同步策略(2026-09-11 更新)
|
||||
## 一、代码同步与发布策略(2026-09-14 更新)
|
||||
|
||||
**代码主仓库已迁移至 Gitea:https://gitea.kasugano.cn/zijunchiang/maidong-ktv**
|
||||
**代码主仓库:Gitea `https://gitea.kasugano.cn/zijunchiang/maidong-ktv`**
|
||||
|
||||
| 远端 | 地址 | 状态 |
|
||||
| 远端 | 地址 | 用途 |
|
||||
|---|---|---|
|
||||
| `origin` | `https://gitea.kasugano.cn/zijunchiang/maidong-ktv.git` | ✅ 主仓库,**同步目标** |
|
||||
| `gitee` | `https://gitee.com/zijunchiang/maidong-ktv.git` | ⛔ 已停用(推送被钩子拦截) |
|
||||
| `origin` | `https://gitea.kasugano.cn/zijunchiang/maidong-ktv.git` | ✅ 代码主仓库,**唯一的代码同步目标** |
|
||||
| `gitee` | `https://gitee.com/zijunchiang/maidong-ktv.git` | 📦 **不推代码**,只用于发布 Release;仓库内只保留 README 文档 + Releases 附件 |
|
||||
|
||||
- ✅ 预期动作:完成改动 → 本地 `git commit` → `git push origin master`(同步到 Gitea)
|
||||
- ⛔ 禁止:推送到 Gitee(`pre-push` 钩子会拦截,避免误推送)
|
||||
- 凭据:Gitea 访问令牌 `dsh-sync`(可在 Gitea「设置 → 应用 → 访问令牌」随时吊销),
|
||||
存放于 `~/.git-credentials-gitea`(权限 600),由本仓库级 `credential.helper` 引用;
|
||||
账号密码不写入仓库配置。
|
||||
### 四条硬性规则
|
||||
|
||||
1. **推送失败只重试 3 次**:Gitea 不可用(网络不通/服务器故障/返回非 200)时,最多重试 **3 次**
|
||||
(间隔约 60s)。3 次仍失败就**停止重试**,改动保留为本地 commit,**留到下次一起同步**
|
||||
(不要无限重试、不要为了"推上去"而改推 Gitee)。恢复同步时直接 `git push origin master`
|
||||
把积压的 commit 一并推上即可。
|
||||
2. **Gitee 不再同步代码,只发布版本**:Gitee 仓库保持「仅 README 文档 + Releases 附件」状态,
|
||||
**禁止推送源码**(`pre-push` 钩子会拦截 `gitee.com`)。Gitee 上只做一件事:创建 Release 并上传 APK 附件。
|
||||
3. **每次更新版本号 +1**:完成一次修改就把版本号递增(`versionName` 与 `versionCode` 同步递增),
|
||||
例如 `1.3.0` → `1.3.1` → `1.3.2`。不要在同一个版本号下反复改动交付。
|
||||
4. **发布是手动操作**:**未收到明确的「发布新版本」指令时,默认只在本地打包测试**——
|
||||
产物按内部测试约定命名 `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-20260926`(2026-09-26 数据迁移后重建;可在 Gitea
|
||||
「设置 → 应用 → 访问令牌」随时吊销),存放于 `~/.git-credentials-gitea`(权限 600),
|
||||
由本仓库级 `credential.helper` 引用;
|
||||
- **Gitee**(仅发布 Release,按规则 2 不推代码):访问令牌存放于 `~/.git-credentials-gitee`
|
||||
(权限 600),由全局 `credential.https://gitee.com.helper` 引用;**只用于 API 建 Release /
|
||||
传附件**,不得用于推送源码。
|
||||
- 账号密码不写入仓库配置。
|
||||
|
||||
### 推送方式
|
||||
|
||||
```bash
|
||||
cd /home/zijun/dsh/maidongktv/maidong-ktv
|
||||
git push origin master # origin = Gitea(主仓库)
|
||||
git push --no-verify gitee master # 仅在确需回推 Gitee 时使用
|
||||
cd /home/zijun/dsh_data/maidongktv/maidong-ktv
|
||||
git push origin master # origin = Gitea(唯一的代码同步目标)
|
||||
```
|
||||
|
||||
> Gitee 不参与代码同步;确需回推文档时用 `git push --no-verify gitee master`,
|
||||
> 但按规则 2 正常情况下不应执行。
|
||||
|
||||
### 运行时端点(已全部迁移到 Gitea)
|
||||
|
||||
App 内部两处端点已指向 Gitea(随本版 APK 生效):
|
||||
@@ -33,38 +58,84 @@ App 内部两处端点已指向 Gitea(随本版 APK 生效):
|
||||
- `KtvJsBridge.kt`:`gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js`(JS 热更新)
|
||||
|
||||
> 注意:Gitea 的 raw 规范路径是 `/raw/branch/<分支>/<文件>`;`/raw/<分支>/<文件>` 会 303 跳转。
|
||||
> 发布新版本时需在 Gitea 创建 Release 并上传 `.apk` 附件(Gitea Release API 的 `assets[].browser_download_url` 供 App 下载)。
|
||||
> Gitea 的 raw 与 release 下载对**无 User-Agent** 的请求同样放行(App 裸 socket 可直连)。
|
||||
|
||||
### 发布新版本(Gitea Release)
|
||||
|
||||
```bash
|
||||
TOKEN=$(sed -E 's|https://zijunchiang:([^@]+)@.*|\1|' ~/.git-credentials-gitea)
|
||||
|
||||
# 1) 创建 Release(tag 需高于当前 versionName 才会提示更新)
|
||||
curl -X POST -u "zijunchiang:$TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"tag_name":"v1.2.6","name":"麦动KTV V1.2.6","body":"更新说明","target_commitish":"master"}' \
|
||||
https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases
|
||||
|
||||
# 2) 上传 APK 附件(用返回的 release id;附件名必须 ASCII)
|
||||
curl -X POST -u "zijunchiang:$TOKEN" \
|
||||
-F "attachment=@麦动KTV-V1.2.6-release.apk" \
|
||||
"https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/<id>/assets?name=maidong-ktv-V1.2.6-release.apk"
|
||||
```
|
||||
|
||||
> ⚠️ **附件文件名必须为 ASCII**:Gitea 1.27 的规范下载路径
|
||||
> `/releases/download/<tag>/<文件名>` 对非 ASCII 文件名返回 404(`/attachments/<uuid>` 直链虽可用,
|
||||
> 但 `releases/latest` API 返回的是规范路径)。因此 Gitea 上的附件用
|
||||
> `maidong-ktv-V1.2.x-release.apk` 这类 ASCII 名;本地留存的发布包仍可命名
|
||||
> `麦动KTV-V1.2.x-release.apk`。
|
||||
>
|
||||
> 上传 22MB APK 约需 70s,注意命令超时设置。
|
||||
|
||||
## 二、构建与产物
|
||||
|
||||
### 构建(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.2.5"` / `versionCode 5`
|
||||
- `app/build.gradle`:`versionName "1.4.0"` / `versionCode 16`
|
||||
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
|
||||
|
||||
## 三、关键实现约束(避免踩坑)
|
||||
@@ -72,5 +143,126 @@ $BT/apksigner verify 目标名.apk
|
||||
- **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底)
|
||||
- **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本
|
||||
- **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件
|
||||
- **曲库同步时效**:默认 **30 天**(`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载)
|
||||
- **麦克风**:`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建
|
||||
- **设备 MAC**:统一由 `DeviceMac` 解析**当前在用网卡**的硬件地址(12 位小写十六进制)。优先
|
||||
`wlan*`/`eth*`/`en*`,忽略虚拟/蜂窝/VPN 接口;Android 6 起 `WifiInfo.getMacAddress()` 恒为
|
||||
`02:00:00:00:00:00`,因此直接读 `NetworkInterface.getHardwareAddress()`;读出 `02:00:00:00:00:00`
|
||||
或全 0 视为无效。读取失败回退 `DeviceMac.FALLBACK_MAC`(历史抓包值),保证身份永不为空。
|
||||
结果按进程缓存,网络切换时由 `remoteNetworkMonitor` 调用 `DeviceMac.refresh()` 重解析。
|
||||
消费方:`KtvJsBridge.DEVICE_MAC`(JS 端 `mac`)、whoami 心跳的 `_mac`/`androidid`/`_cid`
|
||||
(抓包时三者同值,保持一致)
|
||||
- **播放信息实时刷新**:`refreshNowPlayingLabels()` 是播放界面左上角「当前播放 / 下一首」、首页播放条、
|
||||
旧版播放区与顶部状态栏的**唯一**刷新入口,只在文本变化时写 View;已挂到 `lyricTicker`(500ms)上,
|
||||
因此队列变化(本地点歌 / 扫码点歌 / 自动切歌)最迟 500ms 内必然同步。新增播放信息展示时请复用它,
|
||||
不要另起刷新路径
|
||||
- **远程日志**:接入 GlogCenter 集中式日志(HTTP 接口,单条 JSON)。**域名属敏感信息,不得写入任何文档**
|
||||
(端点常量见代码 `MiliLog.ENDPOINT`,需要时查代码,勿在 README/AGENTS 中记录域名)。字段:
|
||||
`system`/`date`/`text`/`servername`/`serverip`/`clientip`/`loglevel`/`traceid`/`user`;`date` 必须为
|
||||
`yyyy-MM-dd HH:mm:ss.SSS`,否则按时间检索异常。其中 `serverip` = **主机 IP**(公网出口 IP,
|
||||
由 `MiliLog.PUBLIC_IP_URL` 查询,结果缓存 5 分钟、失败 1 分钟后重试,查询在独立后台线程,
|
||||
绝不在主线程发起),`clientip` = **客户端 IP**(设备当前网卡获取到的局域网 IP,由
|
||||
`MiliLog.updateDeviceContext(ip = ...)` 注入,网络变化时自动刷新并顺带复核公网 IP)。
|
||||
客户端为 `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 行需要这个反馈;但
|
||||
**大面积展示型表面绝不能加** —— 否则触摸一次(`state_pressed`)就用纯色盖住整屏内容。
|
||||
已排除的表面:播放画面宿主(`home_player_container` / `player_container`)、视频层
|
||||
(`persistentVideo`)、播放触摸层(`playerTouchOverlay`)、全屏播放容器(`R.id.container`)、
|
||||
启动加载遮罩(`databaseLoadingOverlay`)、手机点歌二维码浮层、全屏音量面板。
|
||||
**新增此类铺满/覆盖播放画面的 View 时,必须调用 `TvFocusStyler.suppressFill(view)`**
|
||||
- **音量默认值**:`KtvStore.DEFAULT_MUSIC_VOLUME`(当前 85%)是唯一来源,
|
||||
`KtvStore` 默认值与 `optInt` 兜底、`MainActivity.musicVolume` 初值、调音弹窗「默认」按钮
|
||||
都引用它;远程页面 `assets/mobile/{index.html,app.js}` 的默认值需手工跟改
|
||||
- **音量控制(只控 App 输出,不动系统音量)**:`musicVolume`(0-100) 是 **App 内部输出音量**,
|
||||
统一在 `applyPlaybackMode()` 内按线性系数施加到播放器(`KtvVideoView.setPlaybackVolume` /
|
||||
`selectAudioChannel`)与副音轨播放器(`vocalPlayer` / `embeddedVocalEngine`)。
|
||||
**严禁调用 `AudioManager.setStreamVolume` / `adjustStreamVolume` 修改系统音量**。
|
||||
入口只有两处,且都必须收敛到 `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)
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
- **曲库**:统一走**官方 Muse 同步**(`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`,RSA 登录 + 增量文件校验),不再依赖代码托管仓库承载数据库。
|
||||
- **歌曲源**:接入 `w.w345.my`(`i.php` 取 token → `do.php` 解析播放地址),固定设备标识并复刻签名算法;广告/错号资源(如返回 `wb66.cn/.../719.ts` 或文件名与 musicno 不一致)会被拦截并重试。
|
||||
- **风控应对**:源对陌生出口 IP 存在试用窗口(约 10 分钟,403"检测到异常行为"),App 检测到 403 后弹窗提示更换公网 IP;同 token 退避重试,不放大请求量。
|
||||
- **JS 热更新**:歌曲接口逻辑在 `app/src/main/assets/mobile/ktv_api.js`,安装后从本仓库自动拉取兼容版本(须保留 `KTV_BRIDGE_API: 7` 标记)。
|
||||
- **应用更新**:以 Gitea Release 标签作为版本号(如 `1.2.5`),Release 需附带一个 `.apk` 附件;仅当标签高于当前 `BuildConfig.VERSION_NAME` 时提示下载。
|
||||
- **JS 热更新**:歌曲接口逻辑在 `app/src/main/assets/mobile/ktv_api.js`,安装后从本仓库自动拉取兼容版本(须保留 `KTV_BRIDGE_API: 8` 标记)。
|
||||
- **应用更新**:以 Gitea Release 标签作为版本号(如 `1.3.0`),Release 需附带一个 `.apk` 附件;仅当标签高于当前 `BuildConfig.VERSION_NAME` 时提示下载。
|
||||
|
||||
## 🔧 编译教程
|
||||
|
||||
@@ -132,7 +132,7 @@ adb install -r app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
- **曲库初始化失败**:检查设备网络能否访问 `mconn.cherryonline.cn`;已在设置页提供「重置数据库」。
|
||||
- **点歌报 403 / 无播放地址**:多为歌曲源对当前公网 IP 的试用窗口到期,重启光猫/路由器更换公网 IP 后恢复。
|
||||
- **热更新脚本不生效**:确认远端 `ktv_api.js` 保留 `KTV_BRIDGE_API: 7` 标记,否则内置版本优先。
|
||||
- **热更新脚本不生效**:确认远端 `ktv_api.js` 保留 `KTV_BRIDGE_API: 8` 标记,否则内置版本优先。
|
||||
- **想改签名/ABI**:编辑 `app/build.gradle` 的 `signingConfigs` 与 `ndk.abiFilters` 后重新编译。
|
||||
|
||||
## 项目结构
|
||||
@@ -145,7 +145,7 @@ maidong-ktv/
|
||||
│ │ ├── MuseDbSync.kt # 官方曲库同步
|
||||
│ │ ├── MuseDatabase.kt # 曲库访问
|
||||
│ │ ├── KtvJsBridge.kt # JS 桥(HTTP/RSA/热更新)
|
||||
│ │ ├── MiliLog.kt # 远程 syslog 日志
|
||||
│ │ ├── MiliLog.kt # 远程日志上报(GlogCenter)
|
||||
│ │ └── SongOkDownloadManager.kt # 下载管理
|
||||
│ ├── src/main/assets/mobile/ktv_api.js # 歌曲源逻辑(热更新)
|
||||
│ └── src/main/res/ # 资源 / 证书
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
+2
-2
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.local.ktv"
|
||||
minSdk 21
|
||||
targetSdk 25
|
||||
versionCode 5
|
||||
versionName "1.2.5"
|
||||
versionCode 16
|
||||
versionName "1.4.0"
|
||||
ndk {
|
||||
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
|
||||
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
|
||||
|
||||
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) {
|
||||
@@ -72,20 +73,121 @@ async function refresh() {
|
||||
$("#playButton span:last-child").textContent = data.playing ? "暂停" : "继续";
|
||||
$("#playButton").dataset.action = data.playing ? "pause" : "resume";
|
||||
$("#playButton .control-icon").textContent = data.playing ? "Ⅱ" : "▶";
|
||||
$("#musicVolume").value = data.musicVolume ?? 70;
|
||||
$("#musicVolume").value = data.musicVolume ?? 85;
|
||||
$("#micVolume").value = data.micVolume ?? 70;
|
||||
$("#musicValue").textContent = data.musicVolume ?? 70;
|
||||
$("#musicValue").textContent = data.musicVolume ?? 85;
|
||||
// 麦克风音量由功放/混音器硬件调节(滑杆已禁用), 仅保留显示值。
|
||||
const micHw = $("#micValue");
|
||||
if (micHw) micHw.textContent = data.micVolume ?? 70;
|
||||
$("#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">70</em>%</b><input id="musicVolume" type="range" min="0" max="100" value="70"></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,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)
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,55 @@ import java.util.Locale
|
||||
*
|
||||
* 说明:已移除分片/镜像数据库下载路径(database_publish 不再发布到代码仓库,
|
||||
* 代码仓库此后只存代码)。曲库统一从官方 Muse 服务(mconn.cherryonline.cn mls-api)
|
||||
* 同步,由 [MuseDbSync] 负责登录、同步、校验与安装;本类只做版本文件与可读错误包装。
|
||||
* 同步,由 [MuseDbSync] 负责登录、同步、校验与安装;本类负责同步时效、版本文件
|
||||
* 与可读错误包装。
|
||||
*
|
||||
* 同步时效:默认 **30 天**([SYNC_TTL_DAYS])。未到期时启动不联网同步,直接用
|
||||
* 本地曲库;到期后才向服务器查询版本并在有更新时下载。
|
||||
*/
|
||||
object DatabaseBootstrapper {
|
||||
private const val TAG = "DatabaseBootstrapper"
|
||||
|
||||
/** 曲库同步时效(天):达到时效后才触发服务器同步。 */
|
||||
const val SYNC_TTL_DAYS = 30
|
||||
private const val SYNC_TTL_MS = SYNC_TTL_DAYS * 24L * 60 * 60 * 1000
|
||||
private const val DAY_MS = 24L * 60 * 60 * 1000
|
||||
|
||||
private val versionFile: File get() = File(MuseDatabase.defaultDbFile().parentFile, "db_version.txt")
|
||||
private val syncStampFile: File get() = File(MuseDatabase.defaultDbFile().parentFile, "db_sync_at.txt")
|
||||
|
||||
/** 上次成功同步(或成功校验为最新)的时间戳(毫秒);无记录返回 0。 */
|
||||
fun lastSyncAt(): Long = runCatching {
|
||||
syncStampFile.takeIf { it.exists() }?.readText()?.trim()?.toLongOrNull()
|
||||
}.getOrNull() ?: 0L
|
||||
|
||||
/** 记录一次成功同步/校验,作为 30 天时效的起点。 */
|
||||
fun markSynced(at: Long = System.currentTimeMillis()) {
|
||||
runCatching { syncStampFile.writeText(at.toString()) }
|
||||
}
|
||||
|
||||
/** 同步是否已到期;无记录(从未同步)视为到期。 */
|
||||
fun isSyncDue(now: Long = System.currentTimeMillis()): Boolean {
|
||||
val last = lastSyncAt()
|
||||
return last <= 0L || now - last >= SYNC_TTL_MS
|
||||
}
|
||||
|
||||
/** 距离下次同步的剩余毫秒;已到期或无记录返回 0。 */
|
||||
fun syncRemainingMs(now: Long = System.currentTimeMillis()): Long {
|
||||
val last = lastSyncAt()
|
||||
if (last <= 0L) return 0L
|
||||
return (last + SYNC_TTL_MS - now).coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
/** 同步状态文案(用于设置界面展示)。 */
|
||||
fun syncStatusText(now: Long = System.currentTimeMillis()): String {
|
||||
val last = lastSyncAt()
|
||||
if (last <= 0L) return "未同步"
|
||||
val remaining = syncRemainingMs(now)
|
||||
if (remaining <= 0L) return "已到期"
|
||||
val days = (remaining + DAY_MS - 1) / DAY_MS
|
||||
return "$days 天后同步"
|
||||
}
|
||||
|
||||
/** 远端曲库版本(官方 Muse);失败返回 null。 */
|
||||
fun fetchRemoteVersion(): String? = MuseDbSync.fetchVersion()
|
||||
@@ -23,16 +66,12 @@ object DatabaseBootstrapper {
|
||||
versionFile.takeIf { it.exists() }?.readText()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
}.getOrNull()
|
||||
|
||||
private fun saveLocalDbVersion(version: String) {
|
||||
runCatching { versionFile.writeText(version.trim()) }
|
||||
}
|
||||
|
||||
/** 下载曲库:仅官方 Muse 同步源。 */
|
||||
/** 下载曲库:仅官方 Muse 同步源;成功后记录同步时间(重置 30 天时效)。 */
|
||||
fun download(onProgress: (Int) -> Unit): Result<File> {
|
||||
return runCatching {
|
||||
val file = MuseDbSync.download(onProgress)
|
||||
// MuseDbSync 内部已保存版本;此处幂等兜底。
|
||||
MuseDbSync.fetchVersion()?.let(::saveLocalDbVersion)
|
||||
// MuseDbSync 内部已保存版本文件;这里记录同步时间作为时效起点。
|
||||
markSynced()
|
||||
file
|
||||
}.mapError(::readableError)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.local.ktv
|
||||
|
||||
import android.util.Log
|
||||
import java.net.NetworkInterface
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 设备 MAC 解析 —— 取「当前实际在用的网卡」的硬件地址。
|
||||
*
|
||||
* 背景:歌曲源(w.w345.my / e.ac19.cn 等)与设备心跳(whoami)按 `device`(mac + sn)
|
||||
* 识别设备。早期实现把 mac 写死为抓包设备的固定值,导致所有设备共用同一身份。
|
||||
* 这里改为读取本机当前启用网卡的 MAC(12 位小写十六进制、无分隔符)。
|
||||
*
|
||||
* 取值优先级:
|
||||
* 1) 启用中、非虚拟、名称形如 `wlan*` / `eth*` / `en*` 的网卡(有线优先于无线);
|
||||
* 2) 其它启用中的非虚拟网卡;
|
||||
* 3) [FALLBACK_MAC](历史抓包值,保证读不到时仍沿用旧身份,不会变成空值)。
|
||||
*
|
||||
* 注意:
|
||||
* - Android 6 起 `WifiInfo.getMacAddress()` 恒返回 `02:00:00:00:00:00`,
|
||||
* 因此这里不使用 WifiManager,直接读 [NetworkInterface.getHardwareAddress];
|
||||
* - `NetworkInterface.isUp/isVirtual` 是 API 24 才有的方法,低版本上会抛错,
|
||||
* 统一用 [safeUp]/[safeVirtual] 包一层,失败时按「可用/非虚拟」处理;
|
||||
* - 结果按进程缓存,避免每次点歌/心跳都枚举网卡;网络切换时调用 [refresh] 重新解析。
|
||||
*/
|
||||
object DeviceMac {
|
||||
private const val TAG = "DeviceMac"
|
||||
|
||||
/** 历史抓包设备 MAC:读不到真实网卡时兜底,保证设备身份始终非空。 */
|
||||
const val FALLBACK_MAC = "ae60e9547958"
|
||||
|
||||
private const val HEX = "0123456789abcdef"
|
||||
|
||||
@Volatile private var cached: String? = null
|
||||
|
||||
/** 当前网卡 MAC(小写、无分隔符);读不到返回空串。 */
|
||||
fun current(): String {
|
||||
cached?.let { return it }
|
||||
synchronized(this) {
|
||||
cached?.let { return it }
|
||||
val value = read()
|
||||
cached = value
|
||||
if (value.isEmpty()) Log.w(TAG, "no usable network interface mac, fallback to legacy id")
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前网卡 MAC;读不到时回退到 [FALLBACK_MAC]。 */
|
||||
fun effective(): String = current().ifEmpty { FALLBACK_MAC }
|
||||
|
||||
/** 清空缓存并重新解析(网络切换 / 网卡热插拔后调用)。 */
|
||||
fun refresh(): String {
|
||||
synchronized(this) { cached = null }
|
||||
return current()
|
||||
}
|
||||
|
||||
private fun read(): String {
|
||||
var candidate = ""
|
||||
try {
|
||||
val interfaces = NetworkInterface.getNetworkInterfaces() ?: return ""
|
||||
while (interfaces.hasMoreElements()) {
|
||||
val nif = interfaces.nextElement() ?: continue
|
||||
if (!safeUp(nif) || safeLoopback(nif) || safeVirtual(nif)) continue
|
||||
val name = nif.name.orEmpty().lowercase(Locale.ROOT)
|
||||
if (isIgnoredName(name)) continue
|
||||
val hex = nif.hardwareAddress.toMacHex()
|
||||
if (hex.isEmpty()) continue
|
||||
// 有线/无线网卡直接采用;否则先记下,继续找更合适的。
|
||||
if (isPreferredName(name)) return hex
|
||||
if (candidate.isEmpty()) candidate = hex
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "read network mac failed: ${e.message}")
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
/** 虚拟/点对点/蜂窝接口不算「当前使用的网卡」。 */
|
||||
private fun isIgnoredName(name: String): Boolean =
|
||||
name.startsWith("tun") || name.startsWith("ppp") || name.startsWith("wg") ||
|
||||
name.startsWith("rmnet") || name.startsWith("radio") || name.startsWith("ccmni") ||
|
||||
name.startsWith("pdp") || name.startsWith("dummy") || name.startsWith("sit") ||
|
||||
name.startsWith("ip6tnl") || name.startsWith("gre") || name.contains("vpn")
|
||||
|
||||
private fun isPreferredName(name: String): Boolean =
|
||||
name.startsWith("wlan") || name.startsWith("eth") || name.startsWith("en")
|
||||
|
||||
private fun ByteArray?.toMacHex(): String {
|
||||
if (this == null || size < 6) return ""
|
||||
val sb = StringBuilder(size * 2)
|
||||
for (b in this) {
|
||||
val v = b.toInt() and 0xFF
|
||||
sb.append(HEX[v shr 4]).append(HEX[v and 0x0F])
|
||||
}
|
||||
val hex = sb.toString()
|
||||
// 全 0 与 Android 6+ 的占位 MAC 视为无效。
|
||||
return if (hex == "000000000000" || hex == "020000000000") "" else hex
|
||||
}
|
||||
|
||||
private fun safeUp(nif: NetworkInterface): Boolean =
|
||||
try { nif.isUp } catch (e: Throwable) { true }
|
||||
|
||||
private fun safeLoopback(nif: NetworkInterface): Boolean =
|
||||
try { nif.isLoopback } catch (e: Throwable) { false }
|
||||
|
||||
private fun safeVirtual(nif: NetworkInterface): Boolean =
|
||||
try { nif.isVirtual } catch (e: Throwable) { false }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -42,10 +42,27 @@ class KtvJsBridge(context: Context) {
|
||||
"https://gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js"
|
||||
private const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||
private const val JS_REQUEST_TIMEOUT_MS = 180_000
|
||||
// 当前歌曲源 w.w345.my 使用的设备标识(mac + sn)。与用户抓包示例一致,
|
||||
// w.w345.my 与 e.ac19.cn 同后台,按 device 识别; 随机/变化的 device 会被风控。
|
||||
private const val DEVICE_MAC = "ae60e9547958"
|
||||
// 歌曲源(w.w345.my 与 e.ac19.cn 同后台)按 device 识别; 随机/变化的 device 会被风控。
|
||||
// mac 取本机「当前在用网卡」的硬件地址(读不到时回退历史抓包值, 见 DeviceMac);
|
||||
// sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
|
||||
private val DEVICE_MAC: String get() = DeviceMac.effective()
|
||||
private 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
|
||||
@@ -118,6 +135,23 @@ class KtvJsBridge(context: Context) {
|
||||
|
||||
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,
|
||||
@@ -144,7 +178,7 @@ class KtvJsBridge(context: Context) {
|
||||
Promise.resolve().then(async function() {
|
||||
if (!window.api) {
|
||||
// 歌曲源按 device 识别:随机/每次更换的 device 会被判"异常行为"。
|
||||
// 固定使用抓包注册设备的 mac+sn(与抓包请求一致),并保持版本号一致。
|
||||
// 使用本机当前网卡 mac + 抓包注册的 sn(会话内保持稳定),并保持版本号一致。
|
||||
if (window.KtvApiConfig) {
|
||||
window.KtvApiConfig.VER = '2.0';
|
||||
window.KtvApiConfig.VN = '4.1.3.03281430';
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class KtvStore {
|
||||
@JvmField var autoNext = true
|
||||
@JvmField var originalVocal = true
|
||||
@JvmField var vocalChannelMode = "自动"
|
||||
@JvmField var musicVolume = 70
|
||||
@JvmField var musicVolume = DEFAULT_MUSIC_VOLUME
|
||||
@JvmField var micVolume = 70
|
||||
@JvmField var tone = 0
|
||||
@JvmField var atmosphere = "标准"
|
||||
@@ -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>()
|
||||
@@ -64,7 +70,7 @@ class KtvStore {
|
||||
autoNext = root.optBoolean("autoNext", true)
|
||||
originalVocal = root.optBoolean("originalVocal", true)
|
||||
vocalChannelMode = root.optString("vocalChannelMode", "自动")
|
||||
musicVolume = root.optInt("musicVolume", 70)
|
||||
musicVolume = root.optInt("musicVolume", DEFAULT_MUSIC_VOLUME)
|
||||
micVolume = root.optInt("micVolume", 70)
|
||||
tone = root.optInt("tone", 0)
|
||||
atmosphere = root.optString("atmosphere", "标准")
|
||||
@@ -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)))
|
||||
@@ -203,6 +217,14 @@ class KtvStore {
|
||||
private fun toggleId(ids: MutableSet<String>, id: String): Boolean = if (ids.remove(id)) false else ids.add(id)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* App 内部输出音量默认值(%)。
|
||||
*
|
||||
* 三处音量入口(播放界面-调音、声音设置-歌曲最大音量、远程控制-音乐音量)共用它;
|
||||
* 远程页面 `assets/mobile/{index.html,app.js}` 里的默认值需与之保持一致。
|
||||
*/
|
||||
const val DEFAULT_MUSIC_VOLUME = 85
|
||||
|
||||
@JvmStatic
|
||||
fun stableId(song: Song): String = song.id?.takeIf(String::isNotEmpty)
|
||||
?: song.path?.takeIf(String::isNotEmpty)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,122 +3,228 @@ package com.local.ktv
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.io.BufferedOutputStream
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.Calendar
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
|
||||
/**
|
||||
* 远程日志客户端 —— 复刻为内网/私有部署的 syslog 服务器(zb.kasugano.cn:514)。
|
||||
* 远程日志客户端 —— GlogCenter(gotoeasy/glogcenter)HTTP 接口。
|
||||
*
|
||||
* - 传输: TCP + TLS(信任打包的 raw/mili_log.crt 根证书, 服务器为 Let's Encrypt
|
||||
* 签发链 ISRG Root X1, 故该根即可完成链校验; 连接目标域名与证书 SAN 不同,
|
||||
* 属私有部署惯例, 因此只做链校验、不做主机名校验)。
|
||||
* - 格式: BSD syslog RFC 3164 —— <PRI>MMM dd HH:mm:ss HOSTNAME TAG: message
|
||||
* - 策略: 单后台线程 + 有界队列; 断线自动重连; 尽力而为, 失败不阻塞业务。
|
||||
* 接口:`POST http://log.kasugano.cn/glc/v1/log/add`,`Content-Type: application/json`,
|
||||
* 单条 JSON 对象(批量用 `/glc/v1/log/addBatch`,此处按单条上报)。
|
||||
*
|
||||
* 日志事件:
|
||||
* 1) App 启动时上报设备/网络信息;
|
||||
* 2) 点歌解析歌曲源(w.w345.my / 实际下载域名)时实时上报;
|
||||
* 3) 网络错误/接口 403/下载失败/播放失败实时上报。
|
||||
* 字段(均为字符串,对应 GlogCenter 页面列):
|
||||
* system 系统名(本 App 固定 [SYSTEM])
|
||||
* date 日期时间,格式 `yyyy-MM-dd HH:mm:ss.SSS`(务必与检索格式一致)
|
||||
* text 日志内容
|
||||
* servername 主机名(设备型号)
|
||||
* serverip 主机 IP —— 公网出口 IP,由 [PUBLIC_IP_URL] 查询,结果缓存 [PUBLIC_IP_TTL_MS]
|
||||
* clientip 客户端 IP —— 设备当前网卡获取到的局域网 IP(由调用方注入)
|
||||
* loglevel 日志级别(INFO / WARN / ERROR)
|
||||
* traceid 追踪码(此处用设备标识)
|
||||
* user 用户(此处用设备标识)
|
||||
*
|
||||
* 策略:单后台线程 + 有界队列;发送失败重试一次,连续失败退避;尽力而为,不阻塞业务。
|
||||
* 日志事件:启动设备/网络信息、点歌命中的源与结果、接口 403/网络错误、下载/播放失败。
|
||||
*/
|
||||
object MiliLog {
|
||||
private const val TAG = "MiliLog"
|
||||
private const val HOST = "zb.kasugano.cn"
|
||||
private const val PORT = 514
|
||||
private const val TAG_NAME = "MaidongKTV"
|
||||
private const val FACILITY = 16 // local0
|
||||
private const val MAX_MSG_BYTES = 900
|
||||
private const val ENDPOINT = "http://log.kasugano.cn/glc/v1/log/add"
|
||||
|
||||
/** 公网 IP 查询接口(主机 IP)。 */
|
||||
private const val PUBLIC_IP_URL = "https://ipinfo.io/ip"
|
||||
|
||||
/** 公网 IP 缓存时长:5 分钟内复用,避免频繁调用外部接口。 */
|
||||
private const val PUBLIC_IP_TTL_MS = 5 * 60 * 1000L
|
||||
|
||||
/** 公网 IP 查询失败后的重试间隔。 */
|
||||
private const val PUBLIC_IP_RETRY_MS = 60 * 1000L
|
||||
private const val PUBLIC_IP_TIMEOUT_MS = 6000
|
||||
|
||||
/** GlogCenter 页面上的「系统名」,用于区分应用。 */
|
||||
private const val SYSTEM = "maidongktv"
|
||||
private const val MAX_TEXT_LEN = 900
|
||||
private const val MAX_QUEUE = 300
|
||||
private const val CONNECT_TIMEOUT_MS = 8000
|
||||
private const val SOCKET_TIMEOUT_MS = 10000
|
||||
private const val RETRY_SLEEP_MS = 10_000L
|
||||
|
||||
// RFC 3164 severity
|
||||
private const val SEV_ERROR = 3
|
||||
private const val SEV_WARNING = 4
|
||||
private const val SEV_NOTICE = 5
|
||||
private const val SEV_INFO = 6
|
||||
private const val READ_TIMEOUT_MS = 8000
|
||||
private const val RETRY_SLEEP_MS = 1500L
|
||||
private const val FAILURE_BACKOFF_MS = 10_000L
|
||||
|
||||
private val started = AtomicBoolean(false)
|
||||
private val running = AtomicBoolean(false)
|
||||
private val publicIpFetching = AtomicBoolean(false)
|
||||
private val queue = LinkedBlockingQueue<String>(MAX_QUEUE)
|
||||
private var sslContext: SSLContext? = null
|
||||
private var hostname = "android"
|
||||
|
||||
@Volatile private var socket: SSLSocket? = null
|
||||
private var senderThread: Thread? = null
|
||||
|
||||
/** 用 app context 初始化(加载打包证书、读取设备标识)。可重复调用。 */
|
||||
@Volatile private var serverName: String = "android"
|
||||
|
||||
/** 主机 IP:公网出口 IP([PUBLIC_IP_URL] 查询,5 分钟缓存)。 */
|
||||
@Volatile private var serverIp: String = ""
|
||||
@Volatile private var serverIpAt: Long = 0L
|
||||
|
||||
/** 客户端 IP:设备当前网卡获取到的局域网 IP。 */
|
||||
@Volatile private var clientIp: String = ""
|
||||
@Volatile private var traceId: String = ""
|
||||
@Volatile private var user: String = ""
|
||||
|
||||
/** 用 app context 初始化并启动发送线程。可重复调用。 */
|
||||
fun init(context: Context) {
|
||||
if (started.getAndSet(true)) return
|
||||
val appContext = context.applicationContext
|
||||
hostname = sanitize(Build.MODEL).ifEmpty { "android" }
|
||||
sslContext = buildSslContext(appContext)
|
||||
serverName = sanitize(Build.MODEL).ifEmpty { "android" }
|
||||
running.set(true)
|
||||
senderThread = Thread({ sendLoop() }, "mili-syslog").also { it.isDaemon = true; it.start() }
|
||||
senderThread = Thread({ sendLoop() }, "mili-glog").also { it.isDaemon = true; it.start() }
|
||||
ensureServerIp()
|
||||
}
|
||||
|
||||
private fun sanitize(s: String): String =
|
||||
s.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else ' ' }
|
||||
.joinToString("").trim().replace(Regex("\\s+"), "-")
|
||||
|
||||
private fun buildSslContext(context: Context): SSLContext? = try {
|
||||
val cf = CertificateFactory.getInstance("X.509")
|
||||
val cert = context.resources.openRawResource(R.raw.mili_log).use {
|
||||
cf.generateCertificate(it) as X509Certificate
|
||||
/**
|
||||
* 更新上报用的客户端网络/身份字段。
|
||||
*
|
||||
* @param ip 设备当前网卡获取到的局域网 IP(clientip),变化时会顺带复核公网 IP
|
||||
* @param deviceId 设备标识(同时写入 user / traceid)
|
||||
*/
|
||||
fun updateDeviceContext(ip: String? = null, deviceId: String? = null) {
|
||||
if (!ip.isNullOrBlank() && ip != clientIp) {
|
||||
clientIp = ip
|
||||
// 局域网 IP 变化通常意味着换了网络,公网出口 IP 需要立即复核。
|
||||
ensureServerIp(force = true)
|
||||
}
|
||||
if (!deviceId.isNullOrBlank()) {
|
||||
user = deviceId
|
||||
traceId = deviceId
|
||||
}
|
||||
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
keyStore.load(null)
|
||||
keyStore.setCertificateEntry("mili_log", cert)
|
||||
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
tmf.init(keyStore)
|
||||
SSLContext.getInstance("TLS").apply { init(null, tmf.trustManagers, null) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "buildSslContext failed", e)
|
||||
null
|
||||
}
|
||||
|
||||
// ─── 对外上报接口 ────────────────────────────────
|
||||
fun info(msg: String) = enqueue("INFO", msg)
|
||||
fun notice(msg: String) = enqueue("INFO", msg)
|
||||
fun warn(msg: String) = enqueue("WARN", msg)
|
||||
fun error(msg: String) = enqueue("ERROR", msg)
|
||||
|
||||
fun info(msg: String) = enqueue(SEV_INFO, msg)
|
||||
fun notice(msg: String) = enqueue(SEV_NOTICE, msg)
|
||||
fun warn(msg: String) = enqueue(SEV_WARNING, msg)
|
||||
fun error(msg: String) = enqueue(SEV_ERROR, msg)
|
||||
|
||||
private fun enqueue(severity: Int, msg: String) {
|
||||
if (!started.get() || sslContext == null) return
|
||||
val line = format(severity, msg)
|
||||
if (line == null) return
|
||||
// 队列满时丢最旧, 保证新日志能进队。
|
||||
if (!queue.offer(line)) {
|
||||
private fun enqueue(level: String, msg: String) {
|
||||
if (!started.get()) return
|
||||
// 公网 IP 过期时顺带后台刷新(内部有 5 分钟节流,不会频繁调用外部接口)。
|
||||
ensureServerIp()
|
||||
val body = buildBody(level, msg) ?: return
|
||||
// 队列满时丢最旧,保证新日志能进队。
|
||||
if (!queue.offer(body)) {
|
||||
queue.poll()
|
||||
queue.offer(line)
|
||||
queue.offer(body)
|
||||
}
|
||||
}
|
||||
|
||||
private fun format(severity: Int, msg: String): String? {
|
||||
val body = msg.replace(Regex("[\\r\\n]+"), " ").trim()
|
||||
if (body.isEmpty()) return null
|
||||
val truncated = if (body.toByteArray(Charsets.UTF_8).size > MAX_MSG_BYTES) {
|
||||
body.take(MAX_MSG_BYTES)
|
||||
} else body
|
||||
val now = Calendar.getInstance()
|
||||
val months = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
val day = String.format(Locale.US, "%2d", now.get(Calendar.DAY_OF_MONTH))
|
||||
val time = String.format(Locale.US, "%02d:%02d:%02d",
|
||||
now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE), now.get(Calendar.SECOND))
|
||||
val pri = FACILITY * 8 + severity
|
||||
// RFC3164: <PRI>TIMESTAMP HOSTNAME TAG: MSG (TAG 不含空格)
|
||||
return "<$pri>${months[now.get(Calendar.MONTH)]} $day $time $hostname $TAG_NAME: $truncated"
|
||||
private fun buildBody(level: String, msg: String): String? {
|
||||
val text = msg.replace(Regex("[\\r\\n]+"), " ").trim()
|
||||
if (text.isEmpty()) return null
|
||||
val truncated = if (text.toByteArray(Charsets.UTF_8).size > MAX_TEXT_LEN) {
|
||||
text.take(MAX_TEXT_LEN)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
return runCatching {
|
||||
JSONObject().apply {
|
||||
put("system", SYSTEM)
|
||||
put("date", timestamp())
|
||||
put("text", truncated)
|
||||
put("servername", serverName)
|
||||
put("serverip", serverIp)
|
||||
put("clientip", clientIp)
|
||||
put("loglevel", level)
|
||||
put("traceid", traceId)
|
||||
put("user", user)
|
||||
}.toString()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** GlogCenter 检索要求的时间格式:yyyy-MM-dd HH:mm:ss.SSS */
|
||||
private fun timestamp(): String =
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.ROOT).format(Date())
|
||||
|
||||
private fun sanitize(value: String): String =
|
||||
value.map { if (it.isLetterOrDigit() || it == '-' || it == '_' || it == ' ') it else ' ' }
|
||||
.joinToString("").trim()
|
||||
|
||||
// ─── 公网 IP(主机 IP)────────────────────────────
|
||||
|
||||
/**
|
||||
* 确保主机 IP(公网出口 IP)可用。
|
||||
*
|
||||
* 仅在「从未获取过 / 已超过 [PUBLIC_IP_TTL_MS] / 显式要求复核」时发起查询,
|
||||
* 且同一时刻只允许一个查询在飞。查询在独立后台线程完成,绝不阻塞调用方
|
||||
* (`enqueue` 可能来自主线程)。
|
||||
*/
|
||||
private fun ensureServerIp(force: Boolean = false) {
|
||||
val age = System.currentTimeMillis() - serverIpAt
|
||||
val fresh = serverIp.isNotEmpty() && !force && age < PUBLIC_IP_TTL_MS
|
||||
if (fresh) return
|
||||
if (!publicIpFetching.compareAndSet(false, true)) return
|
||||
Thread({
|
||||
try {
|
||||
val ip = queryPublicIp()
|
||||
if (ip.isNotEmpty()) {
|
||||
serverIp = ip
|
||||
serverIpAt = System.currentTimeMillis()
|
||||
} else {
|
||||
// 查询失败:保留旧值,但延后重试,避免每条日志都触发一次请求。
|
||||
serverIpAt = System.currentTimeMillis() - PUBLIC_IP_TTL_MS + PUBLIC_IP_RETRY_MS
|
||||
}
|
||||
} finally {
|
||||
publicIpFetching.set(false)
|
||||
}
|
||||
}, "mili-pubip").also { it.isDaemon = true; it.start() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待主机 IP 就绪(最多 [timeoutMs]),供启动上报使用。
|
||||
*
|
||||
* 调用方必须在子线程(内部会短暂 sleep);超时未就绪则返回空串,不影响上报。
|
||||
*/
|
||||
fun awaitServerIp(timeoutMs: Long = 3000L): String {
|
||||
ensureServerIp()
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (serverIp.isEmpty() && System.currentTimeMillis() < deadline) {
|
||||
try {
|
||||
Thread.sleep(100L)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
break
|
||||
}
|
||||
}
|
||||
return serverIp
|
||||
}
|
||||
|
||||
/** 查询公网出口 IP([PUBLIC_IP_URL] 返回纯文本 IP);失败返回空串。 */
|
||||
private fun queryPublicIp(): String = try {
|
||||
val connection = (URL(PUBLIC_IP_URL).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = PUBLIC_IP_TIMEOUT_MS
|
||||
readTimeout = PUBLIC_IP_TIMEOUT_MS
|
||||
setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}")
|
||||
}
|
||||
try {
|
||||
val code = connection.responseCode
|
||||
if (code in 200..299) {
|
||||
val text = connection.inputStream.use { it.readBytes().toString(Charsets.UTF_8) }.trim()
|
||||
// 只接受形如 IPv4/IPv6 的纯文本,长度上限 45(IPv6 最长)。
|
||||
if (text.isNotEmpty() && text.length <= 45 &&
|
||||
text.all { it.isLetterOrDigit() || it == '.' || it == ':' }
|
||||
) {
|
||||
text
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "public ip query failed: ${e.message}")
|
||||
""
|
||||
}
|
||||
|
||||
// ─── 发送线程 ────────────────────────────────────
|
||||
@@ -126,76 +232,71 @@ object MiliLog {
|
||||
private fun sendLoop() {
|
||||
var consecutiveFailures = 0
|
||||
while (running.get()) {
|
||||
val line: String = try {
|
||||
val body: String = try {
|
||||
queue.take()
|
||||
} catch (e: InterruptedException) {
|
||||
break
|
||||
}
|
||||
var sent = false
|
||||
var attempt = 0
|
||||
while (!sent && attempt < 2) {
|
||||
attempt++
|
||||
try {
|
||||
val s = connectOrReuse()
|
||||
val out = BufferedOutputStream(s.getOutputStream())
|
||||
out.write(line.toByteArray(Charsets.UTF_8))
|
||||
out.write('\n'.code)
|
||||
out.flush()
|
||||
sent = true
|
||||
var sent = post(body)
|
||||
if (!sent) {
|
||||
sleepQuietly(RETRY_SLEEP_MS)
|
||||
sent = post(body)
|
||||
}
|
||||
if (sent) {
|
||||
consecutiveFailures = 0
|
||||
// 同一连接顺手把队列里已积压的消息发完。
|
||||
// 顺手把已积压的日志发完。
|
||||
var next: String? = queue.poll()
|
||||
while (next != null) {
|
||||
out.write(next.toByteArray(Charsets.UTF_8))
|
||||
out.write('\n'.code)
|
||||
out.flush()
|
||||
post(next)
|
||||
next = queue.poll()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
closeSocket()
|
||||
if (attempt == 1) {
|
||||
Log.w(TAG, "syslog send failed (${e.message}), retrying...")
|
||||
try { Thread.sleep(1500) } catch (ie: InterruptedException) { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sent) {
|
||||
} else {
|
||||
consecutiveFailures++
|
||||
Log.w(TAG, "syslog send dropped: $line")
|
||||
Log.w(TAG, "glog send dropped (failures=$consecutiveFailures): ${body.take(120)}")
|
||||
if (consecutiveFailures >= 3) {
|
||||
consecutiveFailures = 0
|
||||
try { Thread.sleep(RETRY_SLEEP_MS) } catch (ie: InterruptedException) { break }
|
||||
sleepQuietly(FAILURE_BACKOFF_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
closeSocket()
|
||||
}
|
||||
|
||||
private fun connectOrReuse(): SSLSocket {
|
||||
val existing = socket
|
||||
if (existing != null && existing.isConnected && !existing.isClosed && !existing.isOutputShutdown) {
|
||||
return existing
|
||||
private fun post(body: String): Boolean = try {
|
||||
val connection = (URL(ENDPOINT).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}")
|
||||
}
|
||||
val context = sslContext ?: throw IllegalStateException("SSL context not ready")
|
||||
val raw = context.socketFactory.createSocket() as SSLSocket
|
||||
raw.connect(java.net.InetSocketAddress(HOST, PORT), CONNECT_TIMEOUT_MS)
|
||||
raw.soTimeout = SOCKET_TIMEOUT_MS
|
||||
raw.startHandshake()
|
||||
socket = raw
|
||||
Log.i(TAG, "syslog connected to $HOST:$PORT")
|
||||
return raw
|
||||
try {
|
||||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
val code = connection.responseCode
|
||||
// 读取并丢弃响应体,确保请求完成(也便于排查)。
|
||||
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||
runCatching { stream?.use { it.readBytes() } }
|
||||
code in 200..299
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "glog send error: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
private fun closeSocket() {
|
||||
try { socket?.close() } catch (ignored: Exception) {}
|
||||
socket = null
|
||||
private fun sleepQuietly(ms: Long) {
|
||||
try {
|
||||
Thread.sleep(ms)
|
||||
} catch (ignored: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
/** 供测试/关闭时调用。 */
|
||||
/** 停止发送(退出时调用)。 */
|
||||
fun shutdown() {
|
||||
running.set(false)
|
||||
senderThread?.interrupt()
|
||||
senderThread = null
|
||||
closeSocket()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -106,8 +106,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
|
||||
|
||||
@@ -96,6 +100,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 +119,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 +131,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 +266,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 +340,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 +420,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()
|
||||
|
||||
@@ -13,13 +13,26 @@ import java.util.WeakHashMap
|
||||
/** Applies the original OTT red-to-orange focus fill to remote-control targets. */
|
||||
object TvFocusStyler {
|
||||
private val installed = Collections.newSetFromMap(WeakHashMap<View, Boolean>())
|
||||
private val preservedBackgrounds = Collections.newSetFromMap(WeakHashMap<View, Boolean>())
|
||||
private val fillSuppressed = Collections.newSetFromMap(WeakHashMap<View, Boolean>())
|
||||
|
||||
/** Preserve image/video backgrounds while suppressing Android's grey focus halo. */
|
||||
fun preserveBackground(view: View) {
|
||||
preservedBackgrounds.add(view)
|
||||
/**
|
||||
* 禁止对该 View 施加焦点/按下填充 —— 用于「大面积展示型表面」。
|
||||
*
|
||||
* 背景:[install] 会把背景整体替换成不透明的红→橙渐变,作为遥控器焦点/按下的视觉反馈。
|
||||
* 这对按钮、Tab、卡片是期望效果;但对铺满屏幕或覆盖播放画面的表面型 View
|
||||
* (播放器宿主、视频层、播放触摸层、全屏播放容器、启动加载遮罩、二维码浮层)就是灾难:
|
||||
* 只要触摸一次(`state_pressed`)就会用一个纯色层盖住整屏内容,即「纯色遮罩层」。
|
||||
*
|
||||
* 调用后该 View 保留自身背景且不再被 [install] 处理,平台默认灰色高亮也被关闭。
|
||||
*
|
||||
* @param focusable 是否顺带让该 View 可被遥控器聚焦(默认 true,与旧的 [install] 行为一致);
|
||||
* 纯触摸捕获层(如 `playerTouchOverlay`)应传 false,否则会抢走遥控器焦点
|
||||
* 且自身没有任何焦点视觉反馈。
|
||||
*/
|
||||
fun suppressFill(view: View, focusable: Boolean = true) {
|
||||
fillSuppressed.add(view)
|
||||
disablePlatformHighlight(view)
|
||||
if (view.isClickable && view.isEnabled && view.visibility == View.VISIBLE) {
|
||||
if (focusable && view.isClickable && view.isEnabled && view.visibility == View.VISIBLE) {
|
||||
view.isFocusable = true
|
||||
view.isFocusableInTouchMode = false
|
||||
}
|
||||
@@ -37,9 +50,12 @@ object TvFocusStyler {
|
||||
private fun install(view: View, force: Boolean) {
|
||||
disablePlatformHighlight(view)
|
||||
if (!view.isEnabled || view.visibility != View.VISIBLE || !view.isClickable) return
|
||||
// 显式标记为「表面型」的 View: 既不填充背景, 也不替调用方强制可聚焦
|
||||
// (例如 playerTouchOverlay 刻意要求 isFocusable=false, 否则会抢走遥控器焦点)。
|
||||
if (!force && fillSuppressed.contains(view)) return
|
||||
view.isFocusable = true
|
||||
view.isFocusableInTouchMode = false
|
||||
if (!force && (preservedBackgrounds.contains(view) || view.background?.isStateful == true)) return
|
||||
if (!force && view.background?.isStateful == true) return
|
||||
if (!installed.add(view) && !force) return
|
||||
val normal = view.background ?: ColorDrawable(Color.TRANSPARENT)
|
||||
val normalShape = normal as? GradientDrawable
|
||||
|
||||
@@ -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>
|
||||
@@ -1,31 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||
-----END CERTIFICATE-----
|
||||
Reference in New Issue
Block a user