20 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
zijunchiang 4eb6b7b382 V1.3.3: 移除点击时的纯色遮罩层 + App 音量默认值改为 85%
1) 纯色遮罩层: TvFocusStyler.install() 会把背景整体换成不透明的红→橙渐变作为
   焦点/按下反馈, 而 prepareTvFocusableTree(window.decorView) 会遍历整棵树施加。
   按钮/Tab/卡片需要该反馈, 但铺满屏幕或覆盖播放画面的「表面型」View 一旦被套上,
   触摸一次(state_pressed)就会用纯色盖住整屏内容。
   新增 TvFocusStyler.suppressFill(view, focusable) 显式排除:
   - 播放触摸层 playerTouchOverlay(浮在播放画面上方, 主要元凶; 传 focusable=false
     保持其 isFocusable=false, 不再抢遥控器焦点)
   - 全屏播放容器 R.id.container、启动加载遮罩 databaseLoadingOverlay
   - 手机点歌二维码浮层(首页右下 + 全屏右上)、全屏音量面板
   - 原 preserveBackground 更名为 suppressFill(播放画面宿主/海报沿用)
   同时 install() 对已标记的 View 直接跳过, 不再替调用方强制可聚焦。

2) App 音量默认 85%: 新增 KtvStore.DEFAULT_MUSIC_VOLUME 作为唯一来源,
   KtvStore 字段默认值与 optInt 兜底、MainActivity.musicVolume 初值、
   调音弹窗「默认」按钮(原 80)统一引用; 远程页面 assets/mobile 默认值同步为 85。

版本号 1.3.2 -> 1.3.3(versionCode 8 -> 9)。按约定本次不发布 Release, 仅本地打包测试。
2026-09-14 03:37:17 +08:00
zijunchiang 0549f910a6 V1.3.2: 音量控制只作用于 App 内部输出, 不再修改系统音量
三处入口全部收敛到 applyPlaybackMode():
- 播放界面-调音(全屏音量面板 showFullScreenVolumeDialog() 与调音弹窗
  showAudioControlDialog() 的音乐音量滑杆)
- 声音设置-歌曲最大音量
- 远程控制-音乐音量(mobile PATCH /api/v1/player/audio -> remoteAudio())

musicVolume(0-100) 作为 App 内部输出音量, 按线性系数施加到主播放器
(KtvVideoView.setPlaybackVolume / selectAudioChannel)与副音轨播放器
(vocalPlayer / embeddedVocalEngine); 删除 applySystemMusicVolume() 及其 4 处调用,
AudioManager.setStreamVolume / getStreamMaxVolume 已全部移除。

副作用修正: 原先 App 音量 70% 时既把系统音量压到 70%、又把播放器衰减到 70%,
实际响度只有约 49%(重复衰减); 现在只衰减一次, 100% 即 unity gain。

版本号 1.3.1 -> 1.3.2(versionCode 7 -> 8)。按约定本次不发布 Release, 仅本地打包测试。
2026-09-14 03:20:17 +08:00
zijunchiang 221516f319 docs(AGENTS): 更新工作流程 — 推送失败只重试3次后留存本地待下次同步; Gitee 不同步代码只发布版本; 每次更新版本号+1; 发布为手动操作, 未收到指令时仅本地打包测试 2026-09-14 02:59:24 +08:00
zijunchiang 485235e96e V1.3.1: 设备MAC改取当前网卡 + 播放界面当前/下一首实时刷新 + 日志IP与启动信息调整
1) 设备 MAC: 新增 DeviceMac 解析「当前在用网卡」硬件地址(优先 wlan/eth/en,
   忽略虚拟/蜂窝/VPN; 直接读 NetworkInterface.getHardwareAddress, 规避 Android 6+
   WifiInfo 恒返回 02:00:00:00:00:00 的限制), 读出无效值或失败时回退历史抓包值。
   结果按进程缓存, 网络切换时由 remoteNetworkMonitor 重解析。
   消费方: KtvJsBridge.DEVICE_MAC(JS 端 mac)、whoami 心跳 _mac/androidid/_cid。
2) 播放界面: 抽出 refreshNowPlayingLabels() 作为「当前播放/下一首」的唯一刷新入口,
   只在文本变化时写 View, 并挂到 lyricTicker(500ms), 使播放界面左上角、首页播放条、
   旧版播放区与顶部状态栏在本地点歌/扫码点歌/自动切歌后都能实时同步;
   顶部状态栏的「下首」改为按当前曲目位置取其后一首(原先固定取队列第 2 首)。
3) 远程日志: serverip 改为主机 IP(公网出口 IP, 查询后缓存 5 分钟、失败 1 分钟后重试,
   后台线程执行), 新增 clientip 字段承载设备网卡获取到的局域网 IP; 启动上报改为
   采集 10 项: 包名、版本号、安装时间、品牌、型号、设备名、Android 版本、CPU 架构、
   内核版本、屏幕分辨率。
4) 版本号 1.3.0 -> 1.3.1(versionCode 6 -> 7)。本次不发布 Release, 仅本地打包测试。
2026-09-14 02:59:21 +08:00
zijunchiang 2a3c4e6003 docs(AGENTS): 记录 Gitee 仓库已精简为仅 README + Releases 附件 2026-09-12 17:51:33 +08:00
zijunchiang 914450bc6e 发布 V1.3.0: 关于界面标注框架来源, 清理旧日志 TLS 证书, 文档脱敏
- 删除 app/src/main/res/raw/mili_log.crt(旧 syslog/TLS 上报遗留, 已无引用)
- 关于界面(弹窗+设置页)补充: 框架来源 杨家三郎/麦动KTV, 内核 由 DSH 重构数据库源
- app/build.gradle: versionCode 6 / versionName 1.3.0
- 文档: 远程日志域名脱敏(仅指向 MiliLog.ENDPOINT), 同步 KTV_BRIDGE_API: 8 与版本号
2026-09-12 17:47:50 +08:00
zijunchiang 2779237408 日志系统更新为 GlogCenter(HTTP): 替换原 syslog/TLS 上报
- MiliLog 重写: POST http://log.kasugano.cn/glc/v1/log/add (application/json 单条)
  字段 system/date/text/servername/serverip/loglevel/traceid/user
  date 格式 yyyy-MM-dd HH:mm:ss.SSS; 有界队列+后台线程+失败重试/退避
- init 接口保持不变, 各调用点(MainActivity/KtvJsBridge/SongOkDownloadManager)无需改动
- 新增 updateDeviceContext(): 注入设备 IP 与标识; 网络变化时自动刷新
- AGENTS.md 记录新日志系统用法与字段/时间格式约束
2026-09-12 17:24:43 +08:00
zijunchiang b3b8e6a12d 删除 WX_resized.jpg(本地与仓库, 无引用) 2026-09-12 01:56:38 +08:00
zijunchiang b37dbec443 曲库同步时效 30 天: 未到期启动不联网, 到期后才同步服务器数据库
- DatabaseBootstrapper: 新增 SYNC_TTL_DAYS=30 与 db_sync_at.txt 时间戳
  (lastSyncAt/isSyncDue/syncRemainingMs/syncStatusText/markSynced)
- initializeDatabase: 本地曲库可用且未到期时跳过服务器同步(无网络请求);
  到期或本地不可用才查版本并在有更新时下载
- 设置页: 曲库行显示「30 天同步: N 天后/已到期」; 更新按钮改为手动强制检查
- download(): 成功后记录同步时间(去掉一次多余的远端版本请求)
2026-09-11 23:28:09 +08:00
zijunchiang 4c792361a4 docs(AGENTS): 记录 Gitea Release 发布流程与 ASCII 附件名限制 2026-09-11 23:15:13 +08:00
25 changed files with 2646 additions and 1175 deletions
+213 -21
View File
@@ -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)
+4 -4
View File
@@ -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/ # 资源 / 证书
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

+2 -2
View File
@@ -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
+151 -14
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) {
@@ -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);
+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">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 }
}
+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
}
+38 -4
View File
@@ -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
}
}
}
+24 -2
View File
@@ -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
+235 -134
View File
@@ -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
consecutiveFailures = 0
// 同一连接顺手把队列里已积压的消息发完。
var next: String? = queue.poll()
while (next != null) {
out.write(next.toByteArray(Charsets.UTF_8))
out.write('\n'.code)
out.flush()
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 }
}
}
}
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) {
post(next)
next = queue.poll()
}
} 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>
-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>
-31
View File
@@ -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-----