10 Commits
Author SHA1 Message Date
zijunchiang 08b5b438b9 docs: 登记 Gitee 发布令牌位置与用途边界
- 凭据条目拆分为 Gitea(代码主仓库) / Gitee(仅发布 Release) 两条
- 明确 ~/.git-credentials-gitee(600) 由 credential.https://gitee.com.helper 引用,
  只用于 API 建 Release/传附件,不得用于推送源码(规则 2)
- 纯文档改动,版本号保持 1.4.0
2026-09-26 12:35:27 +08:00
zijunchiang 7803d34a42 docs: 数据迁移后修正本机路径与开发环境说明
- AGENTS.md 内 4 处 /home/zijun/dsh/maidongktv → /home/zijun/dsh_data/maidongktv
  (推送方式、构建命令、签名密钥路径 ×2)
- 新增「本机工具链」小节:记录用户态 JDK 17 / git / Android SDK 布局、
  env.sh 统一入口、git 包装脚本原因、迁移导致的 mode change 处理方式
- 纯文档改动,未改 App 代码,版本号保持 1.4.0(versionCode 16)
2026-09-26 12:22:43 +08:00
zijunchiang 942ec527f1 V1.4.0: 修复断网重连后无法下载 + 网络状态检测维护
根因(断网重连后再也下载不了、日志却显示"歌曲源获取成功"):
SongApiClient.clearTokenCache() 一直是**空实现**。JS 侧 window.api.tokens 与
_apiInitPromise 因此被一直复用; 而歌曲源按「出口 IP + device」识别、试用窗口
也记在出口 IP 上(见 Eac19WindowGuard), 断网/重连(尤其换出口 IP)后旧 token 已失效 ——
取址这一步看起来成功, 真正下载却 403/失败。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

版本号 1.3.3 -> 1.3.4(versionCode 9 -> 10)。按约定本次不发布 Release, 仅本地打包测试。
2026-09-20 12:02:07 +08:00
18 changed files with 1938 additions and 899 deletions
+126 -14
View File
@@ -25,15 +25,25 @@
产物按内部测试约定命名 `maidong-ktv-<版本>-<改动说明>-双ABI-release.apk`,
**不创建 Release、不上传附件、不推送 tag、不在 Gitee 建 Release**。
只有收到发布指令后,才按「发布新版本」小节在 Gitea(以及需要的 Gitee)建 Release。
> ⚠️ **不要混淆"交付"与"发布"**:向 Gitea `git push origin master` 同步代码(规则 1)、
> 本地构建/签名出 `*-release.apk`(规则 4 前半段)**都不算发布**,不需要、也不得顺带
> 建 Release / 打 tag / 传附件。发布动作只有三个:建 Release、打 tag、上传附件;
> 三者的触发条件只有一个 —— **收到明确的人工发布指令**。若不确定是否已发布,
> 用 `GET /repos/{owner}/{repo}/releases` 与 `/tags` 核对,不要凭"打过包"推断。
- 凭据:Gitea 访问令牌 `dsh-sync`(可在 Gitea「设置 → 应用 → 访问令牌」随时吊销),
存放于 `~/.git-credentials-gitea`(权限 600),由本仓库级 `credential.helper` 引用;
账号密码不写入仓库配置。
- 凭据:
- **Gitea**(代码主仓库):访问令牌 `dsh-sync-20260926`(2026-09-26 数据迁移后重建;可在 Gitea
「设置 → 应用 → 访问令牌」随时吊销),存放于 `~/.git-credentials-gitea`(权限 600),
由本仓库级 `credential.helper` 引用;
- **Gitee**(仅发布 Release,按规则 2 不推代码):访问令牌存放于 `~/.git-credentials-gitee`
(权限 600),由全局 `credential.https://gitee.com.helper` 引用;**只用于 API 建 Release /
传附件**,不得用于推送源码。
- 账号密码不写入仓库配置。
### 推送方式
```bash
cd /home/zijun/dsh/maidongktv/maidong-ktv
cd /home/zijun/dsh_data/maidongktv/maidong-ktv
git push origin master # origin = Gitea(唯一的代码同步目标)
```
@@ -79,31 +89,53 @@ curl -X POST -u "zijunchiang:$TOKEN" \
### 构建(release,双 ABI)
```bash
cd /home/zijun/dsh/maidongktv/maidong-ktv
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
export ANDROID_HOME=/home/zijun/Android/Sdk
export ANDROID_SDK_ROOT=/home/zijun/Android/Sdk
cd /home/zijun/dsh_data/maidongktv/maidong-ktv
. /home/zijun/dsh_data/maidongktv/env.sh # 导入 JAVA_HOME / ANDROID_HOME / PATH
./gradlew assembleRelease
```
产物:`app/build/outputs/apk/release/app-release.apk`(已由 `app/build.gradle` 的 signingConfig 签名)
### 本机工具链(2026-09-26 数据迁移后重建)
数据迁移后的本机是**纯净环境且当前用户无 sudo**,因此工具链全部装在用户态,
不要再用系统路径(`/usr/lib/jvm/...` 不存在)。统一入口:
```bash
. /home/zijun/dsh_data/maidongktv/env.sh # 已加入 ~/.bashrc,新开 shell 自动生效
```
| 组件 | 路径 | 来源 |
|---|---|---|
| JDK 17 | `/home/zijun/tools/jdk-17`(Temurin 17.0.20.1) | Adoptium tarball 解包 |
| git | `/home/zijun/tools/bin/git`(包装脚本 → `/home/zijun/tools/gitroot/usr/bin/git`) | `apt-get download git` + `dpkg -x` |
| Android SDK | `/home/zijun/Android/Sdk`(与 `local.properties` 的 `sdk.dir` 一致) | `cmdline-tools` + `sdkmanager` |
| 已装 SDK 包 | `platforms;android-35`、`build-tools;34.0.0`、`build-tools;36.0.0`、`platform-tools` | sdkmanager |
| 签名密钥 | `/home/zijun/dsh_data/maidongktv/ktv_keystore.jks`(仓库外,勿入库) | 数据迁移 |
- `git` 包装脚本是必需的:deb 内 git 的编译期 `exec-path` 写死为 `/usr/lib/git-core`
(无 root 无法创建),包装脚本显式设置 `GIT_EXEC_PATH`/`GIT_TEMPLATE_DIR` 后才找得到
`git-remote-https` 等子命令。
- 迁移会把仓库文件权限改成 744(git 记为 1288 个纯 mode change、0 行内容变化)。
遇到大量 `mode change 100644 => 100755` 时,按索引恢复权限即可,不要当成内容改动:
`git ls-files -s -z | ... chmod`(或临时 `git config core.fileMode false`)。
### 签名与校验(单独签名/改名时)
```bash
BT=/home/zijun/Android/Sdk/build-tools/36.0.0
$BT/apksigner sign --ks /home/zijun/dsh/maidongktv/ktv_keystore.jks \
$BT/apksigner sign --ks /home/zijun/dsh_data/maidongktv/ktv_keystore.jks \
--ks-key-alias ktv --ks-pass pass:123456 --key-pass pass:123456 \
--out 目标名.apk app/build/outputs/apk/release/app-release.apk
$BT/apksigner verify 目标名.apk
```
- 密钥:`/home/zijun/dsh/maidongktv/ktv_keystore.jks`(仓库外,勿入库)
- 密钥:`/home/zijun/dsh_data/maidongktv/ktv_keystore.jks`(仓库外,勿入库)
- APK 命名约定:`麦动KTV-V<版本号>-release.apk`(对外发布)/ `maidong-ktv-<版本>-<改动说明>-双ABI-release.apk`(内部测试)
### 版本号
- `app/build.gradle`:`versionName "1.3.3"` / `versionCode 9`
- `app/build.gradle`:`versionName "1.4.0"` / `versionCode 16`
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
## 三、关键实现约束(避免踩坑)
@@ -134,6 +166,46 @@ $BT/apksigner verify 目标名.apk
客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),调用点 `MiliLog.info/notice/warn/error`
保持不变。启动上报(`reportDeviceBootInfo()`)只采集这 10 项:包名、版本号、安装时间、品牌、型号、
设备名、Android 版本、CPU 架构、内核版本、屏幕分辨率
- **网络状态与断网恢复**:`KtvNetworkMonitor`(object,走 `ConnectivityManager` 回调、不轮询)
维护全局 `online`;`MainActivity.onNetworkStateChanged()` 负责:
- 断网 → 播放页右上角 `fullScreenNetworkBadge` 显示「网络未连接」(在全屏控制层 header 内、
标题右侧,不遮挡右上角二维码);
- 恢复 → `refreshNetworkState()`:重解析网卡 MAC + 日志上下文、**重建歌曲源会话**、
复位 `Eac19WindowGuard`、重启本地点歌服务并刷新二维码、自动重试断网期间失败的下载(最多 8 首)。
换网/换 IP 时 `remoteNetworkMonitor` 走同一个 `refreshNetworkState()`(静默,不弹提示)。
⚠️ **「断网重连后再也下载不了、日志却显示歌曲源获取成功」的根因**:
`SongApiClient.clearTokenCache()` 原本是**空实现**,JS 侧 `window.api.tokens` 与
`_apiInitPromise` 会被一直复用;而歌曲源按「出口 IP + device」识别、试用窗口也记在出口 IP 上,
断网/重连(尤其换出口 IP)后旧 token 已失效 —— 取址这一步看起来成功,真正下载却 403/失败。
现在 `clearTokenCache()` 会真正下发 `KtvJsBridge.resetApiSession()`
(置空 `window.api` 与 `_apiInitPromise`,下次取址重新构造 API 实例、回到默认源顺序并重新登录)。
下载层的网络类失败由 `SongOkDownloadManager` 结合 `KtvNetworkMonitor.online` 与异常类型/HTTP 0
判定并记入 `networkFailed`,网络恢复后由 `retryNetworkFailed()` 自动重试(手动取消的不入集合)。
⚠️ 顺带修掉一个隐患:离线时 `localIp()` 为空,原 `remoteNetworkMonitor` 会每 5s 误判「IP 变化」
而反复重启本地点歌服务(并连带触发刷新);现已加空值短路,离线只等网络回调。
- **播放界面右上角二维码**:跟随全屏控制层一起出现/隐藏 —— 在
`setFullScreenChromeVisible()` 里同步 `fullScreenQrOverlay` 的可见性,`updateFullScreenQrOverlay()`
新建时按 `fullScreenChromeVisible` 初始化。**不要**再按「手机是否在点歌」自动隐藏
(原 `qrAutoHideMonitor` 已删除,扫码弹窗里的对应提示文案也一并删除)
- **远程页面滑杆(触控)**:`<input type="range">` **绝不能被 `<label>` 包裹** —— label 会把
内部任意位置的点击转发给 range,表现为"点轨道/点文字就跳值"。行容器用 `<div class="audio-row">`
/ `<div class="eq-band">`;CSS 选择器也随之用类选择器而不是标签选择器。
另外滑杆元素本身高度设 32px(触控目标够大),视觉细轨道由
`::-webkit-slider-runnable-track` / `::-moz-range-track` 绘制,滑块 22px + 5px 边框。
`attachThumbOnlyDrag()` 仍作为第二道防线(命中滑块才放行原生拖动,容差按 32px 滑块算)
- **远程搜索性能**:曲库 DB 有 **67 万首 / 约 1GB**(`muse.db`),搜索是 I/O 密集操作。
实测(同一台机器直连该 DB,`rec_score` 复合索引齐全):
- 中文 `name_trim LIKE '%kw%'`:热态 **2~280ms**(走 `idx_rec_score` 顺序扫描 + 早停,无临时排序)
- 拼音 `name_cap/name_full GLOB 'kw*'`:热态 **0.2~150ms**(前缀范围扫描 + 临时 B-tree 排序;
`A*` 这类高频前缀会扫很多行),冷态(首次读 1GB 文件)可达 **1~15s**
- 歌手检索(`EXISTS` + singers LIKE):热态 **34~70ms**
结论与对策:**瓶颈在冷态 I/O 与重复查询**,因此
1) 设备端加 `remoteSearchCache`(LRU 24 条 = 最近 4 个查询 × 6 页,TTL 60s,曲库刷新时清空),
同一「类型+关键词+页码」直接复用 JSON,翻页与重复搜索不再查库;
2) 手机端:搜索请求用 `AbortController` 取消旧请求、同关键词不重复请求、
首页返回后**预热第 2 页**(把设备缓存捂热)、搜索期间暂停 1s 的状态轮询避免抢 I/O。
⚠️ 不要试图用「一次多取几页」来省请求:实测 `LIMIT 201` 的代价远高于 `LIMIT 21`
(`name_full GLOB` 301 行 6.2s vs 21 行 0.7s,冷态),会把体验做得更差
- **焦点填充与「纯色遮罩层」**:`TvFocusStyler.install()` 会把背景整体换成**不透明**红→橙渐变,
作为遥控器焦点/按下的反馈(`prepareTvFocusableTree(window.decorView)` 与对话框的
`installTree(decorView)` 会遍历整棵树施加)。按钮/Tab/卡片/list 行需要这个反馈;但
@@ -149,8 +221,48 @@ $BT/apksigner verify 目标名.apk
统一在 `applyPlaybackMode()` 内按线性系数施加到播放器(`KtvVideoView.setPlaybackVolume` /
`selectAudioChannel`)与副音轨播放器(`vocalPlayer` / `embeddedVocalEngine`)。
**严禁调用 `AudioManager.setStreamVolume` / `adjustStreamVolume` 修改系统音量**。
三条入口必须全部收敛到 `applyPlaybackMode()`:播放界面-调音(`showFullScreenVolumeDialog()` 与
`showAudioControlDialog()` 的音乐音量滑杆)、声音设置-歌曲最大音量、远程控制-音乐音量
(`remoteAudio()` 的 `musicVolume`)。曲线是纯衰减(100% 即 unity gain),不做系统级增益;
入口只有两处,且都必须收敛到 `applyPlaybackMode()`:**「调音」弹窗**(`showTuningDialog()`,
播放界面与主界面右上角共用同一个弹窗)的音量横向控制杆、远程控制-音乐音量
(`remoteAudio()` 的 `musicVolume`)。**声音设置弹窗里不再有音量滑杆与均衡器入口**
(避免同一功能两套入口),它只保留「切到点播歌曲时音量/原伴唱」这类策略选择。曲线是纯衰减(100% 即 unity gain),不做系统级增益;
设备最终响度仍取决于系统音量,App 不再替用户改动它
- **音频均衡器**:内核 `KtvEqualizer`(参考开源项目 OpenEQ,`Turbofan3360/OpenEQ`,GPL-3.0 的
`EQUtils.kt`)——直接用系统 `android.media.audiofx.Equalizer`,**按设备真实频段数与增益范围自适应**
(频段是动态生成的,不要写死 5 段)。默认把 EQ 挂到**实际播放会话**:
`player.audioSessionId()` / `vocalPlayer.audioSessionId` / `embeddedVocalEngine.audioSessionId()`
(IJK 的原生 `IjkMediaPlayer_getAudioSessionId` 返回真实 AudioTrack 会话)。会话会随歌曲变化,
因此由 500ms 的 `lyricTicker` → `syncEqualizerSessions()` 周期对齐,消失的会话延迟 3s 释放。
`globalMix`(会话 0)是设备不支持按会话挂载时的显式开关,会影响本机所有声音,默认关闭。
均衡器已并入**「调音」弹窗**(`showTuningDialog()`,自上而下:音量横向杆 → 均衡器开关 →
预设 → EQ 频段横向杆,每个频段一行:频率 | 滑杆 | 增益),主界面右上角与播放界面「调音」
是同一个弹窗;另有远程点歌页面「均衡器」卡片(`PATCH /api/v1/player/equalizer`,状态随
`GET /api/v1/state` 的 `equalizer` 字段下发)。预设:App 内为「自定义/流行/摇滚/舞曲」
(`appPresetNames()`,手动改频段即转「自定义」并自动保存),远程页面额外保留「标准」
供「恢复标准」使用。设置持久化在 `KtvStore.eq*`;
**刻度固定 30 档**(`eqSliderSteps`),步进由设备增益范围均分,保证 0 dB 一定可选中。
音量与频段共用自绘控件 `EqSlider`(横向),已 `suppressFill` 并自绘焦点/调节描边。
⚠️ **它的交互是刻意定制的,不要换回 SeekBar**:
- **遥控**:焦点在控件上时方向键**不**改数值(先让焦点在控件间移动);按**确认键进入调节态**
(描边变亮变粗)后方向键才调值,**再按确认键退出**调节态;调节态下 BACK 只退出调节态、
不关弹窗;失焦自动退出。这是"需按确认键才可调节控件点、同理按确认键退出控制"的实现。
- **触控**:必须按住滑块(控件点)才拖动,点轨道空白不跳值(`hitsThumb` 命中判断,
容差 `thumbTouchSlop`)。远程点歌页面同规则,见 `attachThumbOnlyDrag()`(app.js,对
`input[type=range]` 做指针命中判断,未命中则 preventDefault)
- **公放随机清单**:`RandomPlayQueue`(纯内存单例)——随机续播不再「每次现随机挑一首」,
而是一次性生成打乱顺序的临时清单,一轮内不重复;播完一首 `markFinished` +1;
`next()` 每次都重新比对曲库,**自动吸纳新增歌曲**、剔除已删除歌曲;一轮播完自动开新一轮。
**不显示**(界面不展示)、**不关联已点清单**(`orderQueue` 完全独立,随机歌曲不入队)、
**App 退出即清理**(`onDestroy` 里 `isFinishing` 时 `clear()`,不落盘)
- **弹窗分辨率自适应**:TV/投影/盒子的分辨率与 density 差异很大(1280x720、1920x1080,
density 1.0~2.5),固定 dp 尺寸在矮屏上会被屏幕裁切。统一用 `fitDialogToScreen(dialog, scroll,
content, preferredWidthDp, …)`:宽度取 `min(preferredWidthDp, 屏幕宽*0.94)`,高度把内容所在的
ScrollView 限制为 `屏幕高*0.92 - 弹窗标题/按钮框架(chromeDp)` —— **不要再用 `WRAP_CONTENT` 作为
弹窗高度**。内容高度由「屏幕预算 - 固定区高度」反推(调音弹窗的 EQ 频段区就是这样自适应),
预算不足时弹窗内滚动而不是被裁掉。已应用:调音弹窗、扫码点歌弹窗、声音设置弹窗。
新增/改动弹窗时请复用该 helper,别再写死 `setLayout(w, WRAP_CONTENT)`。
⚠️ **文字行必须 wrap_content**:`label()` 自带 6dp 上下内边距,固定矮行(曾用 18/28/42dp)
会把文字裁掉 —— 这是"文字显示不全"的根因。做法是「文字行 wrap_content + 只有无文字区域
才用计算高度」:调音弹窗里唯一可伸缩的是 EQ 频段区,采用**两遍 fit**(先 `panel.measure()`
实测内容高度,超出预算就把频段区减掉,再调 `fitDialogToScreen`),这样收缩的永远是频段区
而不是文字;频段区下限 dp(96),再放不下才滚动。大字体(系统字体缩放)同理自动兜住
- **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403)
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv"
minSdk 21
targetSdk 25
versionCode 9
versionName "1.3.3"
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
+149 -12
View File
@@ -1,9 +1,10 @@
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => document.querySelectorAll(selector);
const uiState = {
page: 1, query: "", searchMode: "singer", busy: false,
page: 1, query: "", searchMode: "song", busy: false,
hotPage: 1, hotPageSize: 10, hotBusy: false,
visible: true, connected: false
visible: true, connected: false, eqEnabled: false,
equalizer: null, searchAbort: null, searchKey: ""
};
function escapeHtml(value) {
@@ -81,11 +82,112 @@ async function refresh() {
$("#original").classList.toggle("active", data.vocalMode === "original");
$("#accompany").classList.toggle("active", data.vocalMode !== "original");
$("#nextSong").textContent = data.next?.title || "队列为空";
// 均衡器卡片默认收起: 收起时跳过 DOM 更新(避免每秒轮询重绘), 展开时再渲染一次。
uiState.equalizer = data.equalizer;
if (!$("#eqBody")?.classList.contains("hidden")) renderEqualizer(data.equalizer);
} catch (_) {
setConnected(false);
}
}
// ─── 均衡器(与设备端 KtvEqualizer 同步,频段由设备真实能力决定)───
function renderEqualizer(eq) {
if (!eq) return;
uiState.eqEnabled = !!eq.enabled;
const state = $("#eqState");
if (state) state.textContent = eq.enabled ? (eq.preset || "自定义") : "已关闭";
const toggle = $("#eqToggle");
if (toggle) {
toggle.textContent = eq.enabled ? "关闭均衡器" : "开启均衡器";
toggle.classList.toggle("active", !!eq.enabled);
}
const hint = $("#eqHint");
if (hint) {
hint.textContent = eq.globalMix
? "当前作用于全局混音(会影响本机所有声音)"
: "均衡器作用于本机播放输出,不改动系统音量";
}
const bands = Array.isArray(eq.bands) ? eq.bands : [];
const container = $("#eqBands");
if (container && container.childElementCount !== bands.length) {
container.innerHTML = bands.map((band, index) => `<div class="eq-band"><span>${escapeHtml(band.label)}Hz</span><b><em id="eqValue${index}">${Number(band.level).toFixed(1)}</em>dB</b><input id="eqBand${index}" type="range" min="${eq.minDb}" max="${eq.maxDb}" step="1" value="${band.level}"></div>`).join("");
bands.forEach((band, index) => {
const input = $("#eqBand" + index);
if (!input) return;
attachThumbOnlyDrag(input);
input.addEventListener("input", () => {
const value = $("#eqValue" + index);
if (value) value.textContent = Number(input.value).toFixed(1);
});
input.addEventListener("change", () => updateEqualizer({ levels: currentEqLevels() }));
});
} else if (container) {
bands.forEach((band, index) => {
const input = $("#eqBand" + index);
// 正在拖动的那根滑杆不要被轮询覆盖。
if (input && document.activeElement !== input) input.value = band.level;
const value = $("#eqValue" + index);
if (value) value.textContent = Number(band.level).toFixed(1);
});
}
const presets = Array.isArray(eq.presets) ? eq.presets : [];
const presetBox = $("#eqPresets");
if (presetBox && presetBox.childElementCount !== presets.length) {
presetBox.innerHTML = presets.map((name) => `<button type="button" class="eq-preset" data-preset="${escapeHtml(name)}">${escapeHtml(name)}</button>`).join("");
presetBox.querySelectorAll(".eq-preset").forEach((button) => {
button.addEventListener("click", () => updateEqualizer({ preset: button.dataset.preset }));
});
}
presetBox?.querySelectorAll(".eq-preset").forEach((button) => {
button.classList.toggle("active", button.dataset.preset === eq.preset);
});
}
function currentEqLevels() {
return Array.from($$("#eqBands input[type=range]")).map((input) => Number(input.value));
}
/**
* 触控优化:滑块必须按住「控件点(滑块)」才能拖动,点轨道空白处不跳变数值。
*
* `<input type="range">` 默认点轨道任意位置都会跳值并开始拖动;这里在指针按下时先做
* 命中判断 —— 命中滑块才放行浏览器原生拖动,否则 preventDefault 拦掉这次手势。
* 只有粘性标记滑块处于按下状态时才放行,手指移出轨道仍可继续拖动(由原生处理)。
*/
function attachThumbOnlyDrag(input) {
if (!input || input.dataset.thumbOnly === "1") return;
input.dataset.thumbOnly = "1";
const blocked = (event) => {
const rect = input.getBoundingClientRect();
const point = event.touches && event.touches.length ? event.touches[0] : event;
const x = point.clientX;
const y = point.clientY;
// 粗略还原轨道几何:控件左右各留半个滑块宽度。
const thumb = 32;
const trackLeft = rect.left + thumb / 2;
const trackRight = rect.right - thumb / 2;
const span = trackRight - trackLeft;
if (span <= 0 || y < rect.top - thumb || y > rect.bottom + thumb) return true;
const min = Number(input.min || 0);
const max = Number(input.max || 100);
const ratio = (Number(input.value) - min) / (max - min || 1);
const thumbX = trackLeft + ratio * span;
// 命中滑块(含手指触控容差)→ 放行拖动;否则拦掉,避免点轨道跳值。
return Math.abs(x - thumbX) > Math.max(thumb, 26);
};
const guard = (event) => { if (blocked(event)) event.preventDefault(); };
input.addEventListener("pointerdown", guard, { passive: false });
input.addEventListener("touchstart", guard, { passive: false });
input.addEventListener("mousedown", guard);
}
async function updateEqualizer(values) {
try {
await api("/api/v1/player/equalizer", { method: "PATCH", body: JSON.stringify(values) });
await refresh();
} catch (error) { toast(error.message); }
}
async function playerAction(action) {
try {
await api("/api/v1/player/actions", { method: "POST", body: JSON.stringify({ action }) });
@@ -101,31 +203,52 @@ async function updateAudio(values) {
}
async function search(reset = true) {
if (uiState.busy) return;
if (reset) {
uiState.page = 1;
uiState.query = $("#query").value.trim();
if (!uiState.query) {
const nextQuery = $("#query").value.trim();
if (!nextQuery) {
toast(`请输入${uiState.searchMode === "singer" ? "歌手" : "歌曲"}名称`);
$("#query").focus();
return;
}
// 同一关键词+同一搜索方式且结果已渲染 → 不重复请求设备(设备端也要查 1GB 曲库)。
const key = `${uiState.searchMode}|${nextQuery}`;
if (key === uiState.searchKey && $("#results").childElementCount) {
$("#searchResultBlock").scrollIntoView({ behavior: "smooth", block: "start" });
return;
}
uiState.page = 1;
uiState.query = nextQuery;
uiState.searchKey = key;
$("#results").innerHTML = "";
$("#searchResultBlock").classList.remove("hidden");
$("#resultTitle").textContent = uiState.searchMode === "singer" ? "歌手搜索结果" : "歌曲搜索结果";
$("#resultSummary").textContent = `正在搜索“${uiState.query}”`;
$("#more").classList.add("hidden");
if (uiState.searchAbort) uiState.searchAbort.abort();
uiState.searchAbort = new AbortController();
}
const signal = uiState.searchAbort ? uiState.searchAbort.signal : undefined;
const requestedPage = uiState.page;
uiState.busy = true;
try {
const data = await api(`/api/v1/songs?q=${encodeURIComponent(uiState.query)}&type=${uiState.searchMode}&page=${uiState.page}&pageSize=20`);
const rows = (data.songs || []).map((song, index) => songRow(song, false, false, (uiState.page - 1) * 20 + index)).join("");
const data = await api(`/api/v1/songs?q=${encodeURIComponent(uiState.query)}&type=${uiState.searchMode}&page=${requestedPage}&pageSize=20`, { signal });
const rows = (data.songs || []).map((song, index) => songRow(song, false, false, (requestedPage - 1) * 20 + index)).join("");
if (rows) $("#results").insertAdjacentHTML("beforeend", rows);
else if (reset) $("#results").innerHTML = '<div class="empty">没有找到相关歌曲<br><small>请更换关键词后重试</small></div>';
$("#resultSummary").textContent = `“${uiState.query}”${data.hasMore ? ",还有更多" : ""}`;
$("#more").classList.toggle("hidden", !data.hasMore);
uiState.page += 1;
} catch (error) { toast(error.message); }
finally { uiState.busy = false; }
uiState.page = requestedPage + 1;
// 预热下一页: 设备端会把它缓存下来, 用户点「更多」时基本瞬时。
if (requestedPage === 1 && data.hasMore) {
window.setTimeout(() => {
api(`/api/v1/songs?q=${encodeURIComponent(uiState.query)}&type=${uiState.searchMode}&page=2&pageSize=20`)
.catch(() => {});
}, 500);
}
} catch (error) {
// 被下一次搜索取消属于正常情况, 不弹提示。
if (error && error.name !== "AbortError") toast(error.message);
} finally { uiState.busy = false; }
}
async function loadHot(page) {
@@ -206,9 +329,23 @@ $("#more").addEventListener("click", () => search(false));
$("#hotPrev").addEventListener("click", () => loadHot(uiState.hotPage - 1));
$("#hotNext").addEventListener("click", () => loadHot(uiState.hotPage + 1));
// 音乐音量可调; 麦克风音量由功放硬件控制, 不再向设备发送无效果请求。
attachThumbOnlyDrag($("#musicVolume"));
$("#musicVolume").addEventListener("input", (event) => { $("#musicValue").textContent = event.target.value; });
$("#musicVolume").addEventListener("change", (event) => updateAudio({ musicVolume: Number(event.target.value) }));
// 均衡器入口(与设备端弹窗等价): 开关 / 恢复标准
$("#eqToggle")?.addEventListener("click", () => updateEqualizer({ enabled: !uiState.eqEnabled }));
$("#eqReset")?.addEventListener("click", () => updateEqualizer({ preset: "标准" }));
// 均衡器文字栏作为折叠按钮: 默认收起; 展开后在同一次页面会话内保持, 重新加载页面回到收起态。
$("#eqCollapse")?.addEventListener("click", () => {
const body = $("#eqBody");
if (!body) return;
const collapsed = body.classList.toggle("hidden");
$("#eqCollapse").setAttribute("aria-expanded", collapsed ? "false" : "true");
// 展开时补一次渲染(收起期间轮询跳过了 EQ 的 DOM 更新)。
if (!collapsed) renderEqualizer(uiState.equalizer);
});
document.addEventListener("visibilitychange", () => { uiState.visible = !document.hidden; if (uiState.visible) refresh(); });
window.setInterval(() => { if (uiState.visible) refresh(); }, 1000);
// 搜索进行中暂停状态轮询: 避免与设备端 1GB 曲库查询抢 I/O。
window.setInterval(() => { if (uiState.visible && !uiState.busy) refresh(); }, 1000);
refresh();
loadHot(1);
+21 -5
View File
@@ -36,8 +36,24 @@
<article class="glass-card audio-panel">
<div class="panel-title"><div><span class="tiny-icon">♪</span><b>声音控制</b></div><small>即时生效</small></div>
<div class="seg"><button id="accompany" data-vocal="accompany"><span>伴</span>伴唱</button><button id="original" data-vocal="original"><span>原</span>原唱</button></div>
<label><span><i class="volume-icon">♪</i>音乐音量</span><b><em id="musicValue">85</em>%</b><input id="musicVolume" type="range" min="0" max="100" value="85"></label>
<label class="mic-hw"><span><i class="volume-icon mic">●</i>麦克风音量</span><small>由功放/混音器硬件调节</small><input id="micVolume" type="range" min="0" max="100" value="70" disabled></label>
<div class="audio-row"><span><i class="volume-icon">♪</i>音乐音量</span><b><em id="musicValue">85</em>%</b><input id="musicVolume" type="range" min="0" max="100" value="85"></div>
<div class="audio-row mic-hw"><span><i class="volume-icon mic">●</i>麦克风音量</span><small>由功放/混音器硬件调节</small><input id="micVolume" type="range" min="0" max="100" value="70" disabled></div>
</article>
<article class="glass-card eq-panel">
<button type="button" class="panel-title eq-title" id="eqCollapse" aria-expanded="false">
<div><span class="tiny-icon">≡</span><b>均衡器</b></div>
<span class="eq-title-right"><small id="eqState">已关闭</small><i class="eq-chevron">▾</i></span>
</button>
<div id="eqBody" class="eq-body hidden">
<div class="seg eq-switch">
<button type="button" id="eqToggle">开启均衡器</button>
<button type="button" id="eqReset">恢复标准</button>
</div>
<div id="eqBands" class="eq-bands"></div>
<div id="eqPresets" class="eq-presets"></div>
<p class="eq-hint" id="eqHint">均衡器作用于本机播放输出,不改动系统音量</p>
</div>
</article>
<article class="next-card"><div class="next-number">02</div><div><small>下一首</small><b id="nextSong">队列为空</b></div><button data-page="queue">查看队列 <span>›</span></button></article>
@@ -46,10 +62,10 @@
<section id="search" class="page">
<div class="page-heading"><div><p>SONG LIBRARY</p><h2>想唱什么?</h2></div><span>海量本地曲库</span></div>
<div class="search-mode" role="group" aria-label="搜索方式">
<button type="button" data-search-mode="singer" class="active">歌手</button>
<button type="button" data-search-mode="song">歌曲</button>
<button type="button" data-search-mode="song" class="active">歌曲</button>
<button type="button" data-search-mode="singer">歌手</button>
</div>
<form id="searchForm" class="search-box"><span class="search-icon"></span><input id="query" autocomplete="off" placeholder="输入歌手名或拼音"><button type="submit">搜索</button></form>
<form id="searchForm" class="search-box"><span class="search-icon"></span><input id="query" autocomplete="off" placeholder="输入歌曲名或拼音"><button type="submit">搜索</button></form>
<div id="searchResultBlock" class="search-result-block hidden">
<div class="block-title"><h3 id="resultTitle">歌手搜索结果</h3><span id="resultSummary"></span></div>
<div id="results" class="list"></div><button id="more" class="more hidden">加载更多歌曲</button>
@@ -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)
}
}
+268
View File
@@ -0,0 +1,268 @@
package com.local.ktv
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
/**
* 横向控制杆 —— 「调音」弹窗的音量与均衡器频段共用。
*
* 交互按两种输入方式分别设计(对应电视遥控与触控):
*
* **遥控器**:
* - 焦点在控件上时,左右/上下键**不**改变数值 —— 先让焦点在控件之间正常移动;
* - 按「确认」键进入**调节态**(控件出现高亮描边),此时左右/上下键才改变数值;
* - 再按一次「确认」退出调节态;调节态下 BACK 也只退出调节态,不关闭弹窗;
* - 失去焦点时自动退出调节态。
*
* **触控**:
* - 必须按住滑块(控件点)才能拖动,点轨道空白处**不会**跳变数值;
* - 在滑块上按下后手指移出控件范围仍可继续拖动,直到抬起。
*
* 数值范围由 [setRange] 给出;[showZeroLine] 为 true 时(均衡器频段)会画出 0 参考线,
* 并从参考线填充到当前值,便于一眼看出提升/衰减。
*/
class EqSlider(context: Context) : View(context) {
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(60, 255, 255, 255)
style = Paint.Style.FILL
}
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(255, 184, 74)
style = Paint.Style.FILL
}
private val zeroPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(110, 255, 255, 255)
strokeWidth = 1.5f
}
private val thumbPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.FILL
}
private val focusPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(255, 152, 48)
style = Paint.Style.STROKE
strokeWidth = 2.5f
}
private val adjustingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(255, 213, 106)
style = Paint.Style.STROKE
strokeWidth = 4f
}
private val density = resources.displayMetrics.density
private val trackHeight = 8f * density
private val thumbRadius = 11f * density
/** 触摸命中滑块的水平容差;只有按住这个范围才允许拖动。 */
private val thumbTouchSlop = 26f * density
private var minValue = 0f
private var maxValue = 100f
private var steps = 100
private var level = 0f
private var showZeroLine = false
/** 是否处于遥控器「调节态」(按确认键进入/退出)。 */
var adjusting: Boolean = false
private set
/** 值变化回调(fromUser=true 表示用户操作触发)。 */
var onLevelChanged: ((level: Float, fromUser: Boolean) -> Unit)? = null
/** 一次调节结束(退出调节态 / 抬手 / 失焦)时回调,便于上层落盘。 */
var onInteractionFinished: (() -> Unit)? = null
private var dragging = false
init {
isFocusable = true
isFocusableInTouchMode = true
isClickable = true
// 自绘控件:禁止 TvFocusStyler 用不透明渐变覆盖背景(自己画焦点/调节描边)。
TvFocusStyler.suppressFill(this)
}
fun setRange(minValue: Float, maxValue: Float, steps: Int, zeroLine: Boolean = false) {
this.minValue = minValue
this.maxValue = maxValue
this.steps = steps.coerceAtLeast(1)
this.showZeroLine = zeroLine
invalidate()
}
fun level(): Float = level
fun setLevel(value: Float, notify: Boolean = false) {
val clamped = value.coerceIn(minValue, maxValue)
if (clamped == level) {
invalidate()
return
}
level = clamped
invalidate()
if (notify) onLevelChanged?.invoke(level, true)
}
private fun stepSize(): Float = (maxValue - minValue) / steps
private fun valueToRatio(value: Float): Float =
((value - minValue) / (maxValue - minValue)).coerceIn(0f, 1f)
private fun ratioToValue(ratio: Float): Float {
val raw = minValue + ratio.coerceIn(0f, 1f) * (maxValue - minValue)
// 吸附到刻度上,保证 0 一定落在某档(均衡器 0dB / 音量整数值)。
val ticks = Math.round((raw - minValue) / stepSize())
return (minValue + ticks * stepSize()).coerceIn(minValue, maxValue)
}
private fun trackLeft(): Float = paddingLeft + thumbRadius
private fun trackRight(): Float = width - paddingRight - thumbRadius
private fun thumbX(): Float =
trackLeft() + valueToRatio(level) * (trackRight() - trackLeft())
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val left = trackLeft()
val right = trackRight()
if (right <= left) return
val centerY = height / 2f
val radius = trackHeight / 2f
canvas.drawRoundRect(
RectF(left, centerY - radius, right, centerY + radius),
radius,
radius,
trackPaint,
)
// 0 参考线(均衡器频段):从参考线填充到当前值。
val zeroRatio = if (showZeroLine) ((0f - minValue) / (maxValue - minValue)).coerceIn(0f, 1f) else 0f
val zeroX = left + zeroRatio * (right - left)
if (showZeroLine) {
canvas.drawLine(zeroX, centerY - trackHeight, zeroX, centerY + trackHeight, zeroPaint)
}
val valueX = thumbX()
val fillStart = minOf(zeroX, valueX)
val fillEnd = maxOf(zeroX, valueX)
if (fillEnd - fillStart > 1f) {
canvas.drawRoundRect(
RectF(fillStart, centerY - radius, fillEnd, centerY + radius),
radius,
radius,
fillPaint,
)
}
canvas.drawCircle(valueX, centerY, thumbRadius, thumbPaint)
if (isFocused) {
val inset = 2f * density
canvas.drawRoundRect(
RectF(inset, inset, width - inset, height - inset),
8f * density,
8f * density,
if (adjusting) adjustingPaint else focusPaint,
)
}
}
/** 触摸点是否落在滑块(控件点)上。 */
private fun hitsThumb(x: Float, y: Float): Boolean {
if (y < 0 || y > height) return false
return Math.abs(x - thumbX()) <= thumbTouchSlop
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 触控优化:只有按住滑块才开始拖动,点空白轨道不跳变数值。
if (!hitsThumb(event.x, event.y)) return false
dragging = true
isPressed = true
if (!isFocused) requestFocus()
return true
}
MotionEvent.ACTION_MOVE -> {
if (!dragging) return false
val span = trackRight() - trackLeft()
if (span <= 0f) return true
val ratio = (event.x - trackLeft()) / span
val next = ratioToValue(ratio)
if (next != level) {
level = next
invalidate()
onLevelChanged?.invoke(level, true)
}
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (!dragging) return false
dragging = false
isPressed = false
performClick()
onInteractionFinished?.invoke()
return true
}
}
return super.onTouchEvent(event)
}
override fun performClick(): Boolean {
super.performClick()
return true
}
private fun step(delta: Int): Boolean {
val current = Math.round((level - minValue) / stepSize())
val next = (current + delta).coerceIn(0, steps)
if (next == current) return false
level = (minValue + next * stepSize()).coerceIn(minValue, maxValue)
invalidate()
onLevelChanged?.invoke(level, true)
return true
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val confirm = keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER ||
keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER
if (confirm) {
// 按确认键:进入 / 退出调节态。
adjusting = !adjusting
invalidate()
if (!adjusting) onInteractionFinished?.invoke()
return true
}
if (keyCode == KeyEvent.KEYCODE_BACK && adjusting) {
adjusting = false
invalidate()
onInteractionFinished?.invoke()
return true
}
val delta = when (keyCode) {
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN -> 1
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP -> -1
else -> return super.onKeyDown(keyCode, event)
}
if (!adjusting) {
// 未进入调节态:不消耗方向键,让焦点在控件之间正常移动。
return super.onKeyDown(keyCode, event)
}
step(delta)
return true
}
override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: android.graphics.Rect?) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
if (!gainFocus && adjusting) {
adjusting = false
invalidate()
onInteractionFinished?.invoke()
}
}
}
@@ -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
}
@@ -47,6 +47,22 @@ class KtvJsBridge(context: Context) {
// 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
@@ -119,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,
@@ -0,0 +1,121 @@
package com.local.ktv
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.util.Log
/**
* 设备网络状态监测(断网 / 恢复),供「网络未连接」提示与断网重连后的会话重建使用。
*
* 只依赖 `ConnectivityManager` 的实时回调,不轮询:
* - `onAvailable` / `onLost` / `onCapabilitiesChanged` 维护「缺省网络是否具备互联网能力」;
* - 状态**变化**时回调上层(已切到主线程),由上层处理提示与恢复动作。
*
* 注意区分两件事:
* - 这里判断的是**系统级网络可用性**(有没有网);
* - 歌曲源的「试用窗口 / 风控」是按**出口 IP** 计的(见 [Eac19WindowGuard]),
* 换网/重连后即使有网也可能需要重新建立会话 —— 这由上层在恢复回调里处理。
*
* 全局可读 [online],便于下载层判断失败是否由断网引起。
*/
object KtvNetworkMonitor {
private const val TAG = "KtvNetworkMonitor"
/** 当前是否有可用网络(默认 true,避免启动瞬间误报断网)。 */
@Volatile
var online: Boolean = true
private set
private val mainHandler = Handler(Looper.getMainLooper())
private var manager: ConnectivityManager? = null
private var callback: ConnectivityManager.NetworkCallback? = null
/** 开始监测;[onChange] 只在状态真正变化时回调(主线程)。 */
fun start(context: Context, onChange: (online: Boolean) -> Unit) {
if (callback != null) return
val cm = context.applicationContext
.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
manager = cm
val initial = currentOnline(cm)
online = initial
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
publish(true, onChange)
}
override fun onLost(network: Network) {
// 掉了一个网络不代表彻底没网(可能还有以太网/其它网络),重新判定一次。
publish(currentOnline(manager), onChange)
}
override fun onCapabilitiesChanged(network: Network, caps: NetworkCapabilities) {
publish(caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET), onChange)
}
}
callback = cb
val registered = runCatching {
cm?.registerNetworkCallback(
NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build(),
cb,
)
true
}.getOrElse {
Log.w(TAG, "registerNetworkCallback failed: ${it.message}")
false
}
if (!registered) {
callback = null
manager = null
}
Log.i(TAG, "monitor started, online=$initial registered=$registered")
onChange(initial)
}
fun stop() {
val cb = callback ?: return
runCatching { manager?.unregisterNetworkCallback(cb) }
.onFailure { Log.w(TAG, "unregister failed: ${it.message}") }
callback = null
manager = null
Log.i(TAG, "monitor stopped")
}
private fun publish(value: Boolean, onChange: (Boolean) -> Unit) {
mainHandler.post {
if (value == online) return@post
online = value
Log.i(TAG, "network state changed: online=$value")
onChange(value)
}
}
/** 主动复检一次(例如从后台回到前台),有变化才回调。 */
fun refresh(onChange: (online: Boolean) -> Unit) {
publish(currentOnline(manager), onChange)
}
private fun currentOnline(cm: ConnectivityManager?): Boolean {
val manager = cm ?: return true
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val network = manager.activeNetwork ?: return false
val caps = manager.getNetworkCapabilities(network) ?: return false
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
} else {
@Suppress("DEPRECATION")
manager.activeNetworkInfo?.isConnected == true
}
} catch (e: Throwable) {
Log.w(TAG, "currentOnline failed: ${e.message}")
true
}
}
}
@@ -47,6 +47,12 @@ class KtvStore {
@JvmField var tableBroadcastEnabled = true
@JvmField var tableBroadcastSeconds = 8
@JvmField var tableBroadcastText = "欢迎光临"
// ===== 均衡器(KtvEqualizer)=====
@JvmField var eqEnabled = false
@JvmField var eqPreset = "自定义"
/** 各频段增益(dB),逗号分隔;不同设备频段数可能不同,加载后会按实际频段数对齐。 */
@JvmField var eqLevels = ""
@JvmField var eqGlobalMix = false
@JvmField val orderQueue = mutableListOf<Song>()
@JvmField val sangHistory = mutableListOf<Song>()
@JvmField val scoreHistory = mutableListOf<String>()
@@ -96,6 +102,10 @@ class KtvStore {
tableBroadcastEnabled = root.optBoolean("tableBroadcastEnabled", true)
tableBroadcastSeconds = root.optInt("tableBroadcastSeconds", 8)
tableBroadcastText = root.optString("tableBroadcastText", "欢迎光临")
eqEnabled = root.optBoolean("eqEnabled", false)
eqPreset = root.optString("eqPreset", "自定义")
eqLevels = root.optString("eqLevels", "")
eqGlobalMix = root.optBoolean("eqGlobalMix", false)
readSongs(root.optJSONArray("orderQueue"), orderQueue)
readSongs(root.optJSONArray("sangHistory"), sangHistory)
root.optJSONArray("scoreHistory").copyStringsTo(scoreHistory)
@@ -164,6 +174,10 @@ class KtvStore {
put("tableBroadcastEnabled", tableBroadcastEnabled)
put("tableBroadcastSeconds", tableBroadcastSeconds)
put("tableBroadcastText", tableBroadcastText)
put("eqEnabled", eqEnabled)
put("eqPreset", eqPreset)
put("eqLevels", eqLevels)
put("eqGlobalMix", eqGlobalMix)
put("orderQueue", writeSongs(orderQueue))
put("sangHistory", writeSongs(sangHistory))
put("scoreHistory", JSONArray(scoreHistory.take(100)))
File diff suppressed because it is too large Load Diff
@@ -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()
@@ -58,6 +58,9 @@ class KtvVideoView @JvmOverloads constructor(
fun setTone(step: Int) = engine?.setTone(step) ?: Unit
fun selectAudioTrack(original: Boolean): Boolean = engine?.selectAudioTrack(original) == true
fun audioTrackCount(): Int = engine?.audioTrackCount() ?: 0
/** 当前播放的音频会话 id(供均衡器等音效挂载);未知返回 0。 */
fun audioSessionId(): Int = engine?.audioSessionId() ?: 0
fun selectAudioChannel(channel: Int, volume: Float) = engine?.selectAudioChannel(channel, volume) ?: Unit
val isPlaying: Boolean get() = engine?.isPlaying == true
val currentPosition: Int get() = engine?.currentPosition ?: 0
@@ -290,6 +293,12 @@ class KtvPlaybackEngine(context: Context) {
ijkPlayer?.trackInfo?.count { it.trackType == ITrackInfo.MEDIA_TRACK_TYPE_AUDIO } ?: 0
}.getOrDefault(0)
/**
* IJK 原生返回的真实 AudioTrack 会话 id(`IjkMediaPlayer_getAudioSessionId`)。
* 音频均衡器需要它才能把音效挂到本播放器上;播放器尚未建立音轨时返回 0。
*/
fun audioSessionId(): Int = runCatching { ijkPlayer?.audioSessionId ?: 0 }.getOrDefault(0)
fun selectAudioChannel(channel: Int, volume: Float) {
val safeVolume = volume.coerceIn(0f, 1f)
val ijk = ijkPlayer
@@ -1,244 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
调音/音频控制弹窗(对应"弹窗-调音.jpg")
包含:
- 4 个开关按钮:立体声/伴唱/原唱/静音
- 2 个滑块:麦克风(音量+效果)、音乐音量
- 关闭按钮
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="640dp"
android:layout_height="wrap_content"
android:background="@drawable/bg_dialog_panel"
android:orientation="vertical"
android:padding="20dp">
<!-- 标题 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="36dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:layout_marginBottom="14dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="🎚 调音设置"
android:textColor="#FF27CCA4"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/btn_audio_close"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@drawable/bg_btn_dialog_close"
android:gravity="center"
android:text="×"
android:textColor="#FFCCCCCC"
android:textSize="22sp" />
</LinearLayout>
<!-- 4 个开关按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="20dp"
android:weightSum="4">
<TextView
android:id="@+id/btn_tone_stereo"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_toggle_on"
android:gravity="center"
android:text="🔊 立体声"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
<TextView
android:id="@+id/btn_tone_accomp"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_toggle_off"
android:gravity="center"
android:text="🎤 伴唱"
android:textColor="#FFCCCCCC"
android:textSize="14sp" />
<TextView
android:id="@+id/btn_tone_origin"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_toggle_on"
android:gravity="center"
android:text="🎙 原唱"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
<TextView
android:id="@+id/btn_tone_mute"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_toggle_off"
android:gravity="center"
android:text="🔇 静音"
android:textColor="#FFCCCCCC"
android:textSize="14sp" />
</LinearLayout>
<!-- 麦克风(增益/效果由功放混音器硬件调节, 滑杆仅作指示) -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
android:text="麦克风音量(功放/混音器硬件调节)"
android:textColor="#FFE0E0FF"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="14dp">
<SeekBar
android:id="@+id/seek_mic_volume"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="100"
android:progress="60"
android:progressTint="#FF27CCA4"
android:thumbTint="#FF27CCA4"
android:enabled="false" />
<TextView
android:id="@+id/text_mic_volume_value"
android:layout_width="50dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="60"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
android:text="麦克风效果(功放/混音器硬件调节)"
android:textColor="#FFE0E0FF"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="20dp">
<SeekBar
android:id="@+id/seek_mic_effect"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="100"
android:progress="50"
android:progressTint="#FF27CCA4"
android:thumbTint="#FF27CCA4"
android:enabled="false" />
<TextView
android:id="@+id/text_mic_effect_value"
android:layout_width="50dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="50"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
</LinearLayout>
<!-- 音乐音量 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="音乐音量"
android:textColor="#FFE0E0FF"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="20dp">
<SeekBar
android:id="@+id/seek_music_volume"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="100"
android:progress="80"
android:progressTint="#FF27CCA4"
android:thumbTint="#FF27CCA4" />
<TextView
android:id="@+id/text_music_volume_value"
android:layout_width="50dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="80"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
</LinearLayout>
<!-- 底部按钮行 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/btn_audio_default"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:background="@drawable/bg_btn_glass"
android:gravity="center"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:text="恢复默认"
android:textColor="#FFCCCCCC"
android:textSize="13sp" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<TextView
android:id="@+id/btn_audio_save"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:background="@drawable/bg_btn_save"
android:gravity="center"
android:paddingStart="28dp"
android:paddingEnd="28dp"
android:text="保存"
android:textColor="#FFFFFFFF"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
-104
View File
@@ -1,104 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="470dp"
android:layout_height="340dp"
android:background="@drawable/bg_tuning_dialog"
android:orientation="vertical"
android:paddingStart="28dp"
android:paddingTop="20dp"
android:paddingEnd="28dp"
android:paddingBottom="22dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="44dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="调音"
android:textColor="#FFFFFFFF"
android:textSize="24sp" />
<TextView
android:id="@+id/btn_tuning_close"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="end|center_vertical"
android:gravity="center"
android:text="×"
android:textColor="#99FFFFFF"
android:textSize="22sp" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="116dp"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_marginEnd="12dp"
android:background="@drawable/bg_tuning_counter"
android:gravity="center"
android:orientation="vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:text="升降调" android:textColor="#BFFFFFFF" android:textSize="17sp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="54dp"
android:gravity="center" android:orientation="horizontal">
<TextView android:id="@+id/btn_tone_down" android:layout_width="48dp" android:layout_height="48dp"
android:gravity="center" android:text="−" android:textColor="#BFFFFFFF" android:textSize="34sp" />
<TextView android:id="@+id/text_tone_counter" android:layout_width="56dp" android:layout_height="48dp"
android:gravity="center" android:text="0" android:textColor="#BFFFFFFF" android:textSize="20sp" />
<TextView android:id="@+id/btn_tone_up" android:layout_width="48dp" android:layout_height="48dp"
android:gravity="center" android:text="+" android:textColor="#BFFFFFFF" android:textSize="30sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_tuning_counter"
android:gravity="center"
android:orientation="vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:text="麦克风" android:textColor="#FFFFFFFF" android:textSize="17sp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="54dp"
android:gravity="center" android:orientation="horizontal">
<TextView android:id="@+id/btn_mic_down" android:layout_width="48dp" android:layout_height="48dp"
android:gravity="center" android:text="−" android:textColor="#FFFFFFFF" android:textSize="34sp" />
<TextView android:id="@+id/text_mic_counter" android:layout_width="56dp" android:layout_height="48dp"
android:gravity="center" android:text="50" android:textColor="#FFFFFFFF" android:textSize="20sp" />
<TextView android:id="@+id/btn_mic_up" android:layout_width="48dp" android:layout_height="48dp"
android:gravity="center" android:text="+" android:textColor="#FFFFFFFF" android:textSize="30sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal"
android:weightSum="4">
<TextView android:id="@+id/btn_preset_pop" android:layout_width="0dp" android:layout_height="92dp"
android:layout_weight="1" android:gravity="center" android:text="♬\n流行" android:textColor="#FFFFFFFF" android:textSize="16sp" />
<TextView android:id="@+id/btn_preset_lyric" android:layout_width="0dp" android:layout_height="92dp"
android:layout_weight="1" android:gravity="center" android:text="▥\n抒情" android:textColor="#FFFFFFFF" android:textSize="16sp" />
<TextView android:id="@+id/btn_preset_rock" android:layout_width="0dp" android:layout_height="92dp"
android:layout_weight="1" android:gravity="center" android:text="♨\n摇滚" android:textColor="#FFFFFFFF" android:textSize="16sp" />
<TextView android:id="@+id/btn_preset_singer" android:layout_width="0dp" android:layout_height="92dp"
android:layout_weight="1" android:gravity="center" android:text="♪\n唱将" android:textColor="#FFFFFFFF" android:textSize="16sp" />
</LinearLayout>
</LinearLayout>