Compare commits
22
Commits
5cd98f9327
..
v1.3.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eb6b7b382 | ||
|
|
0549f910a6 | ||
|
|
221516f319 | ||
|
|
485235e96e | ||
|
|
2a3c4e6003 | ||
|
|
914450bc6e | ||
|
|
2779237408 | ||
|
|
b3b8e6a12d | ||
|
|
b37dbec443 | ||
|
|
4c792361a4 | ||
|
|
ba31caaa38 | ||
|
|
21eca10ee2 | ||
|
|
57341280e4 | ||
|
|
3dad59b8de | ||
|
|
267405d1f3 | ||
|
|
61ad000daf | ||
|
|
197b01f806 | ||
|
|
9dc0d4b994 | ||
|
|
b5e605bade | ||
|
|
ff0ae402ef | ||
|
|
303e86b475 | ||
|
|
2d3751064f |
@@ -0,0 +1,156 @@
|
||||
# 麦动 KTV — 项目开发约定
|
||||
|
||||
> 本文件供后续开发(含 AI 助手会话)遵循,优先级高于默认习惯。
|
||||
|
||||
## 一、代码同步与发布策略(2026-09-14 更新)
|
||||
|
||||
**代码主仓库: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` | 📦 **不推代码**,只用于发布 Release;仓库内只保留 README 文档 + Releases 附件 |
|
||||
|
||||
### 四条硬性规则
|
||||
|
||||
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 访问令牌 `dsh-sync`(可在 Gitea「设置 → 应用 → 访问令牌」随时吊销),
|
||||
存放于 `~/.git-credentials-gitea`(权限 600),由本仓库级 `credential.helper` 引用;
|
||||
账号密码不写入仓库配置。
|
||||
|
||||
### 推送方式
|
||||
|
||||
```bash
|
||||
cd /home/zijun/dsh/maidongktv/maidong-ktv
|
||||
git push origin master # origin = Gitea(唯一的代码同步目标)
|
||||
```
|
||||
|
||||
> Gitee 不参与代码同步;确需回推文档时用 `git push --no-verify gitee master`,
|
||||
> 但按规则 2 正常情况下不应执行。
|
||||
|
||||
### 运行时端点(已全部迁移到 Gitea)
|
||||
|
||||
App 内部两处端点已指向 Gitea(随本版 APK 生效):
|
||||
|
||||
- `AppUpdateManager.kt`:`gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/latest`(App 内检查更新)
|
||||
- `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 的 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
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
|
||||
产物:`app/build/outputs/apk/release/app-release.apk`(已由 `app/build.gradle` 的 signingConfig 签名)
|
||||
|
||||
### 签名与校验(单独签名/改名时)
|
||||
|
||||
```bash
|
||||
BT=/home/zijun/Android/Sdk/build-tools/36.0.0
|
||||
$BT/apksigner sign --ks /home/zijun/dsh/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`(仓库外,勿入库)
|
||||
- APK 命名约定:`麦动KTV-V<版本号>-release.apk`(对外发布)/ `maidong-ktv-<版本>-<改动说明>-双ABI-release.apk`(内部测试)
|
||||
|
||||
### 版本号
|
||||
|
||||
- `app/build.gradle`:`versionName "1.3.3"` / `versionCode 9`
|
||||
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
|
||||
|
||||
## 三、关键实现约束(避免踩坑)
|
||||
|
||||
- **歌曲源**:`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 架构、内核版本、屏幕分辨率
|
||||
- **焦点填充与「纯色遮罩层」**:`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()`:播放界面-调音(`showFullScreenVolumeDialog()` 与
|
||||
`showAudioControlDialog()` 的音乐音量滑杆)、声音设置-歌曲最大音量、远程控制-音乐音量
|
||||
(`remoteAudio()` 的 `musicVolume`)。曲线是纯衰减(100% 即 unity gain),不做系统级增益;
|
||||
设备最终响度仍取决于系统音量,App 不再替用户改动它
|
||||
- **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403)
|
||||
+3
-2
@@ -59,6 +59,8 @@
|
||||
|
||||
## Project Notice
|
||||
|
||||
> **This repository**: [https://gitea.kasugano.cn/zijunchiang/maidong-ktv](https://gitea.kasugano.cn/zijunchiang/maidong-ktv)
|
||||
>
|
||||
> **UI/feature framework credit**: [https://gitee.com/yangyachao-X/maidong-ktv](https://gitee.com/yangyachao-X/maidong-ktv)
|
||||
>
|
||||
> This repository reworks the upstream "Maidong KTV" UI and feature framework, and the core/data layer was
|
||||
@@ -68,10 +70,9 @@
|
||||
Highlights of the reworked data layer:
|
||||
|
||||
- **Library**: synced exclusively from the official Muse service
|
||||
(`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`); Gitee hosts code only, no database files.
|
||||
(`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`); the code host carries source only, no database files.
|
||||
- **Song source**: `w.w345.my` (`i.php` token → `do.php` playback URL) with fixed device identity and
|
||||
reproduced signing; ad/mismatched resources (e.g. `wb66.cn/.../719.ts`) are blocked and retried.
|
||||
- **Remote logging**: optional RFC 3164 syslog over TCP+TLS (`zb.kasugano.cn:514`).
|
||||
- **JS hot-update**: `app/src/main/assets/mobile/ktv_api.js`, requires the `KTV_BRIDGE_API: 7` marker.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
|
||||
## 📌 项目说明
|
||||
|
||||
> **本仓库**:[https://gitea.kasugano.cn/zijunchiang/maidong-ktv](https://gitea.kasugano.cn/zijunchiang/maidong-ktv)
|
||||
>
|
||||
> **内容框架来源**:[https://gitee.com/yangyachao-X/maidong-ktv](https://gitee.com/yangyachao-X/maidong-ktv)
|
||||
>
|
||||
> 本仓库基于上游「麦动 KTV」的界面与功能框架进行改造,并使用 **DSH(DeepSeek Harness)** 重写内核与数据源:重新实现曲库同步、歌曲源接入、设备认证、风控应对、远程日志与广告屏蔽等底层逻辑。上游框架的版权归原作者所有,本项目仅作学习与技术交流。
|
||||
@@ -46,12 +48,11 @@
|
||||
|
||||
## 内核与数据源(DSH 重写部分)
|
||||
|
||||
- **曲库**:统一走**官方 Muse 同步**(`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`,RSA 登录 + 增量文件校验),不再依赖 Gitee 承载数据库;Gitee 仓库仅存代码。
|
||||
- **曲库**:统一走**官方 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 退避重试,不放大请求量。
|
||||
- **远程日志**:可选的 syslog 客户端(RFC 3164,TCP+TLS,`zb.kasugano.cn:514`),启动上报设备/网络信息,点歌实时记录「编号 / 标题 / 请求源 / 状态」。
|
||||
- **JS 热更新**:歌曲接口逻辑在 `app/src/main/assets/mobile/ktv_api.js`,安装后从本仓库自动拉取兼容版本(须保留 `KTV_BRIDGE_API: 7` 标记)。
|
||||
- **应用更新**:以 Gitee 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` 时提示下载。
|
||||
|
||||
## 🔧 编译教程
|
||||
|
||||
@@ -131,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` 后重新编译。
|
||||
|
||||
## 项目结构
|
||||
@@ -144,7 +145,7 @@ maidong-ktv/
|
||||
│ │ ├── MuseDbSync.kt # 官方曲库同步
|
||||
│ │ ├── MuseDatabase.kt # 曲库访问
|
||||
│ │ ├── KtvJsBridge.kt # JS 桥(HTTP/RSA/热更新)
|
||||
│ │ ├── MiliLog.kt # 远程 syslog 日志
|
||||
│ │ ├── MiliLog.kt # 远程日志上报(GlogCenter)
|
||||
│ │ └── SongOkDownloadManager.kt # 下载管理
|
||||
│ ├── src/main/assets/mobile/ktv_api.js # 歌曲源逻辑(热更新)
|
||||
│ └── src/main/res/ # 资源 / 证书
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
+2
-2
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.local.ktv"
|
||||
minSdk 21
|
||||
targetSdk 25
|
||||
versionCode 5
|
||||
versionName "1.2.5"
|
||||
versionCode 9
|
||||
versionName "1.3.3"
|
||||
ndk {
|
||||
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
|
||||
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
|
||||
|
||||
@@ -15,3 +15,5 @@
|
||||
.rank-pager button:disabled{opacity:.35;box-shadow:none}
|
||||
.rank-pager b{min-width:64px;text-align:center;color:#b9a9c9;font-size:11px}
|
||||
.empty.compact{padding:42px 15px}
|
||||
|
||||
.audio-panel label.mic-hw{opacity:.55}.audio-panel label.mic-hw>small{grid-column:1/3;color:#9f8eb0;font-size:10px;margin-top:2px}.audio-panel label.mic-hw input[type=range]{background:rgba(255,255,255,.14)}
|
||||
@@ -72,10 +72,12 @@ 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;
|
||||
$("#micValue").textContent = data.micVolume ?? 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 || "队列为空";
|
||||
@@ -203,10 +205,9 @@ $("#searchForm").addEventListener("submit", (event) => { event.preventDefault();
|
||||
$("#more").addEventListener("click", () => search(false));
|
||||
$("#hotPrev").addEventListener("click", () => loadHot(uiState.hotPage - 1));
|
||||
$("#hotNext").addEventListener("click", () => loadHot(uiState.hotPage + 1));
|
||||
["music", "mic"].forEach((kind) => {
|
||||
$("#" + kind + "Volume").addEventListener("input", (event) => { $("#" + kind + "Value").textContent = event.target.value; });
|
||||
$("#" + kind + "Volume").addEventListener("change", (event) => updateAudio({ [kind + "Volume"]: Number(event.target.value) }));
|
||||
});
|
||||
// 音乐音量可调; 麦克风音量由功放硬件控制, 不再向设备发送无效果请求。
|
||||
$("#musicVolume").addEventListener("input", (event) => { $("#musicValue").textContent = event.target.value; });
|
||||
$("#musicVolume").addEventListener("change", (event) => updateAudio({ musicVolume: Number(event.target.value) }));
|
||||
document.addEventListener("visibilitychange", () => { uiState.visible = !document.hidden; if (uiState.visible) refresh(); });
|
||||
window.setInterval(() => { if (uiState.visible) refresh(); }, 1000);
|
||||
refresh();
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
<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><span><i class="volume-icon mic">●</i>麦克风音量</span><b><em id="micValue">70</em>%</b><input id="micVolume" type="range" min="0" max="100" value="70"></label>
|
||||
<label><span><i class="volume-icon">♪</i>音乐音量</span><b><em id="musicValue">85</em>%</b><input id="musicVolume" type="range" min="0" max="100" value="85"></label>
|
||||
<label class="mic-hw"><span><i class="volume-icon mic">●</i>麦克风音量</span><small>由功放/混音器硬件调节</small><input id="micVolume" type="range" min="0" max="100" value="70" disabled></label>
|
||||
</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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* KTV Song API - JS Hot Update Module
|
||||
* ====================================
|
||||
* 从 Gitee 热更新, App 无需发版即可修复 API 变更
|
||||
* 从 Gitea 热更新, App 无需发版即可修复 API 变更
|
||||
*
|
||||
* 用法:
|
||||
* const api = new KtvApi({proxy: 'http://127.0.0.1:7897'})
|
||||
@@ -12,7 +12,7 @@
|
||||
(function(global) {
|
||||
'use strict';
|
||||
|
||||
// KTV_BRIDGE_API: 7 (Android 缓存校验标记,请勿删除)
|
||||
// KTV_BRIDGE_API: 8 (Android 缓存校验标记,请勿删除)
|
||||
|
||||
// ─── 配置 ──────────────────────────────────────────
|
||||
const CONFIG = {
|
||||
@@ -21,8 +21,16 @@
|
||||
SDK_KEY: '19042303a8374f67ae3fe1e25c97936f',
|
||||
VN: '4.100.5.11121504',
|
||||
VER: '4',
|
||||
// 歌曲源: 仅 w.w345.my(e.ac19.cn 同后台的另一入口, 无 api.php 登记端点)。
|
||||
HOSTS: ['http://w.w345.my'],
|
||||
// 歌曲源(均为同一后台的多个入口; 运行时按可用性自动排序, 解析失败的源沉底)。
|
||||
HOSTS: [
|
||||
'http://w.w345.my',
|
||||
'http://desktok.top',
|
||||
'http://mm.kk456.top',
|
||||
'http://w.w345.my:66',
|
||||
'http://z.w345.my',
|
||||
'http://b.sktv.top',
|
||||
'http://w345.my',
|
||||
],
|
||||
HOST: 'http://w.w345.my',
|
||||
MWS: 'https://mws.cherryonline.cn',
|
||||
RSA_PUBKEY: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAucL0oFErd7REM6TSNa3EZdN1YaOs4J1eCybLPyoQ9ru3q1HU67agC9FzhrCG/RvAQUya5iPmQ8Caed05vqcCVcyJChmkSOGQ7DVShe2rGuTMNlpoRV6UzfcraaVS++7m2K/+kSZJ8OAhhhVuqPruMjsFYpdtstAwvyZT28b+eENwzpp9UHqsooZc7FZ0H8kTbs6XMkw4nIWo+4HoPAhNLEY+xdHvwY6drF/3WDTvsaoMrs73TVQCEEHzZNIz2H/is9VLMnIyOfnfcJi9br78Fj2xHzxu3sAySBOTVLmUMxqYh/g1ox5OXGcW93HJkQLkBi42tFAEkWYlYyl93+jbbQIDAQAB'
|
||||
@@ -142,6 +150,15 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
// 任务3: 点歌结果上报(成功/失败 + 实际命中的源) → Android 侧写入远程日志。
|
||||
function reportSongResult(ok, host, musicno, detail) {
|
||||
try {
|
||||
if (global.android && typeof global.android.reportSongResult === 'function') {
|
||||
global.android.reportSongResult(ok ? 'ok' : 'fail', host, String(musicno), String(detail || ''));
|
||||
}
|
||||
} catch(e) { /* 日志失败不影响点歌 */ }
|
||||
}
|
||||
|
||||
async function httpGet(url, timeout) {
|
||||
timeout = timeout || 8000;
|
||||
// 原 APK 的 native GET 抓包只带 Accept: */*,不伪造来源 IP。
|
||||
@@ -185,6 +202,9 @@
|
||||
this.mac = options.mac || randomHex(16);
|
||||
this.sn = options.sn || randomHex(16);
|
||||
this.tokens = {};
|
||||
// 歌曲源自动排序: 维护一份"当前优先级"列表(运行时可变)。
|
||||
// 某源解析失败时把它挪到队尾, 下次点歌优先尝试更可靠的源。
|
||||
this.sourceOrder = (options.hosts && options.hosts.length ? options.hosts : CONFIG.HOSTS).slice();
|
||||
// 与 test_ktv_unified.py 一致:首次设备加最多 3 次换设备重试。
|
||||
this.maxDeviceRetries = Number.isFinite(Number(options.maxDeviceRetries))
|
||||
? Math.max(0, Number(options.maxDeviceRetries) | 0) : 3;
|
||||
@@ -196,6 +216,26 @@
|
||||
KtvApi.prototype = {
|
||||
_log: function(msg) { if (this.debug) console.log('[KtvApi]', msg); },
|
||||
|
||||
/** 将失败的源移到优先级队尾(自动排序)。host 不在列表时忽略。 */
|
||||
demoteSource: function(host) {
|
||||
var idx = this.sourceOrder.indexOf(host);
|
||||
if (idx < 0) return;
|
||||
this.sourceOrder.splice(idx, 1);
|
||||
this.sourceOrder.push(host);
|
||||
this._log('Source demoted to end (解析失败): ' + host);
|
||||
this._log('Source order now: ' + this.sourceOrder.join(' > '));
|
||||
},
|
||||
|
||||
/** 成功解析的源提到队首(最近成功优先; 曾失败的源恢复后自然回到前面)。 */
|
||||
promoteSource: function(host) {
|
||||
var idx = this.sourceOrder.indexOf(host);
|
||||
if (idx <= 0) return;
|
||||
this.sourceOrder.splice(idx, 1);
|
||||
this.sourceOrder.unshift(host);
|
||||
this._log('Source promoted to front (解析成功): ' + host);
|
||||
this._log('Source order now: ' + this.sourceOrder.join(' > '));
|
||||
},
|
||||
|
||||
regenerateDevice: function() {
|
||||
this.mac = randomHex(16);
|
||||
this.sn = randomHex(16);
|
||||
@@ -299,69 +339,89 @@
|
||||
},
|
||||
|
||||
// 3. Get Song Download URL
|
||||
// 当前仅使用 w.w345.my(与 e.ac19.cn 同后台的另一入口, 只对 ls=1 返回真源)。
|
||||
// 结构保留 host/device 循环, 便于日后恢复多节点/备用源。
|
||||
// 多源自动排序: 按 this.sourceOrder(当前优先级)依次尝试; 某源解析失败
|
||||
// (token 失败 / do.php 非 200 / 广告错号 / 无有效 URL)时立即 demote 到队尾,
|
||||
// 下次点歌自动优先使用更可靠的源。全部源失败返回 null。
|
||||
getSongUrl: async function(musicno, resolution, h265, _preferredLs) {
|
||||
resolution = resolution || '720';
|
||||
h265 = !!h265;
|
||||
var preferredLs = String(_preferredLs == null ? '1' : _preferredLs);
|
||||
var self = this;
|
||||
var lastDetail = '';
|
||||
var lastAttemptHost = '';
|
||||
for (var deviceRound = 0; deviceRound <= self.maxDeviceRetries; deviceRound++) {
|
||||
if (deviceRound > 0) self.regenerateDevice();
|
||||
for (var hostIndex = 0; hostIndex < CONFIG.HOSTS.length; hostIndex++) {
|
||||
var host = CONFIG.HOSTS[hostIndex];
|
||||
self._log((hostIndex === 0 ? 'Primary' : 'Fallback') +
|
||||
' host, device round ' + (deviceRound + 1) + ': ' + host);
|
||||
// 每一轮使用当时的优先级快照; demote 动态修改 sourceOrder。
|
||||
var order = self.sourceOrder.slice();
|
||||
for (var hostIndex = 0; hostIndex < order.length; hostIndex++) {
|
||||
var host = order[hostIndex];
|
||||
lastAttemptHost = host;
|
||||
self._log('Try source (' + (hostIndex + 1) + '/' + order.length + '): ' + host);
|
||||
|
||||
var token = await self.getToken(host, false);
|
||||
if (!token) { self._log('token-fail: ' + host); continue; }
|
||||
if (!token) {
|
||||
lastDetail = 'token-fail';
|
||||
self._log('token-fail: ' + host + ' → demote');
|
||||
self.demoteSource(host);
|
||||
continue;
|
||||
}
|
||||
|
||||
// w.w345.my 与 e.ac19.cn 同后台, 只试 ls=1(其余 ls 值会被其风控直接拒绝,
|
||||
// 反复试会放大请求量)。若未来恢复多节点/备用源, 需按节点保留 ls 轮询。
|
||||
// 各源同后台, 只试 ls=1(其余 ls 值会被其风控直接拒绝, 反复试会放大请求量)。
|
||||
var lsValues = ['1'];
|
||||
var hostOk = false;
|
||||
var hostDetail = '';
|
||||
|
||||
for (var lsi = 0; lsi < lsValues.length; lsi++) {
|
||||
for (var lsi = 0; lsi < lsValues.length && !hostOk; lsi++) {
|
||||
var ls = lsValues[lsi];
|
||||
try {
|
||||
var data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265);
|
||||
// 抓包证实: i.php 下发的 token 恒定不变(端到端同一值, 不随 time/请求变化),
|
||||
// do.php 返回 403"检测到异常行为"是风控限流, 与 token 过期无关。
|
||||
// 原版遇 403 不重取 token, 而是同 token 间隔几秒重试数次后停手(等风控窗口
|
||||
// 过去再成功)。因此这里: 403/失败/广告命中时同 token 短退避重试一次,
|
||||
// 不再清 token 重取 i.php(那会多打一倍请求, 反而放大限流)。
|
||||
// 任务4: do.php code=200 但返回广告/错号资源(如 wb66 719.ts)也算失败,
|
||||
// 退避重试一次, 给服务端机会返回与 musicno 一致的真源。
|
||||
// token 恒定不变; do.php 403"检测到异常行为"是风控限流, 与 token 过期无关。
|
||||
// 403/失败/广告命中时同 token 短退避重试一次, 不再清 token 重取 i.php。
|
||||
// 广告/错号资源(如 wb66 719.ts)也算失败, 退避重试一次等真源。
|
||||
var needRetry = data.code !== 200;
|
||||
if (!needRetry && data.code === 200 && isDemoUrl(data.data || '', musicno)) {
|
||||
self._log('广告/错号资源 (ls=' + ls + '): ' + String(data.data).slice(0, 90));
|
||||
needRetry = true;
|
||||
}
|
||||
if (needRetry) {
|
||||
self._log('do.php code=' + data.code + ' http=' + (data.httpStatus || 0) +
|
||||
' for ' + musicno + ' ls=' + ls + '; retry once with same token');
|
||||
hostDetail = 'do.php code=' + data.code + ' http=' + (data.httpStatus || 0);
|
||||
self._log(hostDetail + ' for ' + musicno + ' ls=' + ls + '; retry once with same token');
|
||||
await sleep(3000);
|
||||
data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265);
|
||||
}
|
||||
|
||||
if (data.code === 200) {
|
||||
var songUrl = data.data || '';
|
||||
// 任务4: 广告/错误资源屏蔽——返回的文件编号与请求不一致即视为广告
|
||||
// (例: 请求 7018878 却返回 719.ts / wb66 demo 等), 一律丢弃。
|
||||
// 任务4: 返回的文件编号与请求不一致即视为广告/错误资源, 一律丢弃。
|
||||
if (songUrl && !isDemoUrl(songUrl, musicno) && /^https?:\/\//i.test(songUrl)) {
|
||||
self._log('URL OK: ' + musicno + ' ls=' + ls + ' via ' + host);
|
||||
self.promoteSource(host);
|
||||
hostOk = true;
|
||||
// 任务3: 点歌成功上报命中的源(Android 侧写入远程日志)。
|
||||
reportSongResult(true, host, musicno, 'ok');
|
||||
return songUrl;
|
||||
}
|
||||
if (isDemoUrl(songUrl, musicno))
|
||||
self._log('仍为广告/错号资源 (ls=' + ls + '): ' + String(songUrl).slice(0, 90));
|
||||
// 不是 demo 但也无效 (空/格式不对) → 继续试下一个 ls
|
||||
if (isDemoUrl(songUrl, musicno)) {
|
||||
hostDetail = '仍为广告/错号资源 (ls=' + ls + ')';
|
||||
self._log(hostDetail + ': ' + String(songUrl).slice(0, 90));
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
hostDetail = e.message;
|
||||
self._log('Failed ls=' + ls + ': ' + host + ', ' + e.message);
|
||||
}
|
||||
}
|
||||
self._log('All ls values exhausted for ' + host);
|
||||
|
||||
// 该源所有 ls 都未取到有效 URL → 判定解析失败, 排到最后。
|
||||
if (!hostOk) {
|
||||
lastDetail = hostDetail || 'no-url';
|
||||
self._log('Source failed (' + (hostDetail || 'no-url') + ') → demote: ' + host);
|
||||
self.demoteSource(host);
|
||||
}
|
||||
}
|
||||
}
|
||||
self._log('All sources exhausted for ' + musicno + ' (' + lastDetail + ')');
|
||||
// 任务3: 全部源失败 → 上报最后尝试的源与原因。
|
||||
reportSongResult(false, lastAttemptHost, musicno, lastDetail || 'all-sources-failed');
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -375,7 +435,7 @@
|
||||
// ─── 导出 ──────────────────────────────────────────
|
||||
global.KtvApi = KtvApi;
|
||||
global.KtvApiConfig = CONFIG;
|
||||
global.KtvBridgeApiVersion = 7;
|
||||
global.KtvBridgeApiVersion = 8;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};
|
||||
|
||||
@@ -11,10 +11,10 @@ import java.io.FileOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/** Gitee Release based in-app updater. */
|
||||
/** Gitea Release based in-app updater. */
|
||||
object AppUpdateManager {
|
||||
private const val LATEST_RELEASE_API =
|
||||
"https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases/latest"
|
||||
"https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/latest"
|
||||
|
||||
data class Release(
|
||||
val version: String,
|
||||
@@ -54,7 +54,7 @@ object AppUpdateManager {
|
||||
apkName = apkName,
|
||||
)
|
||||
})
|
||||
}, "gitee-update-check").start()
|
||||
}, "gitea-update-check").start()
|
||||
}
|
||||
|
||||
fun download(
|
||||
@@ -105,7 +105,7 @@ object AppUpdateManager {
|
||||
require(partial.renameTo(target)) { "无法保存安装包" }
|
||||
target
|
||||
})
|
||||
}, "gitee-update-download").start()
|
||||
}, "gitea-update-download").start()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,14 +7,57 @@ import java.util.Locale
|
||||
/**
|
||||
* 曲库下载引导(仅官方 Muse 同步源)。
|
||||
*
|
||||
* 说明:已移除 Gitee 数据库分片/镜像下载路径(database_publish 不再发布到 Gitee,
|
||||
* Gitee 仓库此后只存代码)。曲库统一从官方 Muse 服务(mconn.cherryonline.cn mls-api)
|
||||
* 同步,由 [MuseDbSync] 负责登录、同步、校验与安装;本类只做版本文件与可读错误包装。
|
||||
* 说明:已移除分片/镜像数据库下载路径(database_publish 不再发布到代码仓库,
|
||||
* 代码仓库此后只存代码)。曲库统一从官方 Muse 服务(mconn.cherryonline.cn mls-api)
|
||||
* 同步,由 [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 }
|
||||
}
|
||||
@@ -30,20 +30,22 @@ import javax.crypto.Cipher
|
||||
* 在隐藏 WebView 中运行可热更新的歌曲接口脚本。
|
||||
*
|
||||
* JS 决定接口地址、参数、签名和响应解析;Android 只提供 HTTP 与 RSA 这两个
|
||||
* WebView 中不可靠的底层能力。这样接口变化时只需更新 Gitee 上的 ktv_api.js。
|
||||
* WebView 中不可靠的底层能力。这样接口变化时只需更新 Gitea 上的 ktv_api.js。
|
||||
*/
|
||||
class KtvJsBridge(context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "KtvJsBridge"
|
||||
private const val JS_FILE = "mobile/ktv_api.js"
|
||||
private const val CACHE_FILE = "ktv_api_cache.js"
|
||||
// 热更新脚本地址(Gitea 主仓库; raw 规范路径为 /raw/branch/<分支>/<文件>)
|
||||
private const val REMOTE_JS_URL =
|
||||
"https://gitee.com/zijunchiang/maidong-ktv/raw/master/app/src/main/assets/mobile/ktv_api.js"
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -57,6 +59,11 @@ class KtvJsBridge(context: Context) {
|
||||
private var loadedFromCache = false
|
||||
private var assetRetryAttempted = false
|
||||
|
||||
/** 最近一次点歌实际命中的歌曲源 host(由 JS reportSongResult 更新, 供远程日志)。 */
|
||||
@Volatile
|
||||
var lastSongSourceHost: String = ""
|
||||
private set
|
||||
|
||||
/** 必须最终在主线程创建 WebView;可从任意线程调用。 */
|
||||
fun init(callback: (Boolean) -> Unit) {
|
||||
mainHandler.post {
|
||||
@@ -138,7 +145,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';
|
||||
@@ -212,7 +219,7 @@ class KtvJsBridge(context: Context) {
|
||||
runCatching {
|
||||
file.isFile && file.length() in 1_001..500_000 &&
|
||||
file.readText(Charsets.UTF_8).let {
|
||||
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 7")
|
||||
it.contains("KtvApi") && it.contains("KTV_BRIDGE_API: 8")
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
|
||||
@@ -238,14 +245,14 @@ class KtvJsBridge(context: Context) {
|
||||
val script = String(bytes, StandardCharsets.UTF_8)
|
||||
require(
|
||||
script.length > 1000 && script.contains("KtvApi") &&
|
||||
script.contains("KTV_BRIDGE_API: 7"),
|
||||
script.contains("KTV_BRIDGE_API: 8"),
|
||||
) { "Invalid or incompatible JS file" }
|
||||
tempFile.writeText(script, Charsets.UTF_8)
|
||||
if (!tempFile.renameTo(cacheFile)) {
|
||||
cacheFile.writeText(script, Charsets.UTF_8)
|
||||
tempFile.delete()
|
||||
}
|
||||
Log.i(TAG, "JS cache updated from Gitee (${script.length} chars)")
|
||||
Log.i(TAG, "JS cache updated from Gitea (${script.length} chars)")
|
||||
// 启动后刚拉到的新版本直接生效;若已有请求在执行,则留到下次启动。
|
||||
mainHandler.post {
|
||||
if (ready && callbacks.isEmpty() && !destroyed) {
|
||||
@@ -296,6 +303,21 @@ class KtvJsBridge(context: Context) {
|
||||
fun httpPost(url: String, body: String, headersJson: String, timeoutMs: Int): String =
|
||||
request("POST", url, body, headersJson, timeoutMs)
|
||||
|
||||
// 任务3: JS 点歌结果上报(成功/失败 + 实际命中的歌曲源 host)。
|
||||
// 由 Kotlin 调用方(SongOkDownloadManager)补充标题后写入远程日志,
|
||||
// 同时成功即表示窗口/网络恢复 → 复位 e.ac19 窗口守卫提示状态。
|
||||
@JavascriptInterface
|
||||
fun reportSongResult(status: String, host: String, musicno: String, detail: String) {
|
||||
val ok = status == "ok"
|
||||
if (host.isNotBlank()) lastSongSourceHost = host
|
||||
if (ok) Eac19WindowGuard.noteUrlOk()
|
||||
// 具体含标题的日志由 SongOkDownloadManager 在拿到 URL 后统一写,
|
||||
// 这里只留简短来源记录, 避免重复刷屏。
|
||||
if (!ok) {
|
||||
MiliLog.error("点歌失败 编号=$musicno 请求源=$host 详情=${detail.take(160)}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun request(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String {
|
||||
// 歌曲源(如 w.w345.my)的 do.php 风控只放行"干净"请求(原版仅 Host+Accept;
|
||||
// 带任何 UA/Connection/Accept-Encoding 头都会被拒)。裸 Socket 只发 Host+JS 头。
|
||||
|
||||
@@ -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 = "标准"
|
||||
@@ -64,7 +64,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", "标准")
|
||||
@@ -203,6 +203,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)
|
||||
|
||||
@@ -39,6 +39,7 @@ class LocalRemoteServer(private val context: Context) {
|
||||
@Volatile private var boundAddress: String? = null
|
||||
@Volatile private var boundPort = -1
|
||||
@Volatile private var lastError: String? = null
|
||||
@Volatile private var lastActivityAtMs = 0L
|
||||
private var server: ServerSocket? = null
|
||||
private var callback: Callback? = null
|
||||
|
||||
@@ -98,6 +99,13 @@ class LocalRemoteServer(private val context: Context) {
|
||||
fun port(): Int = boundPort
|
||||
fun address(): String? = boundAddress
|
||||
fun error(): String? = lastError
|
||||
|
||||
/** 是否在 [withinMs] 内有手机端请求(扫码点歌页面可见时每秒轮询 state)。 */
|
||||
fun hasRecentActivity(withinMs: Long): Boolean {
|
||||
val last = lastActivityAtMs
|
||||
return last > 0 && System.currentTimeMillis() - last <= withinMs
|
||||
}
|
||||
|
||||
fun url(): String? = if (running && boundAddress != null && boundPort > 0) {
|
||||
"http://$boundAddress:$boundPort/mobile"
|
||||
} else null
|
||||
@@ -142,6 +150,8 @@ class LocalRemoteServer(private val context: Context) {
|
||||
val path = target.substringBefore('?')
|
||||
val query = parseQuery(target.substringAfter('?', ""))
|
||||
val body = String(bodyBytes, 0, offset, Charsets.UTF_8)
|
||||
// 手机端已成功建立会话并访问页面/接口 → 记录活跃(用于控制二维码显隐)。
|
||||
lastActivityAtMs = System.currentTimeMillis()
|
||||
val response = when {
|
||||
path == "/" || path == "/mobile" || path == "/mobile/" -> asset("mobile/index.html", "text/html; charset=utf-8")
|
||||
path == "/mobile/app.css" -> asset("mobile/app.css", "text/css; charset=utf-8")
|
||||
|
||||
@@ -5,6 +5,9 @@ import android.app.AlertDialog
|
||||
import android.content.DialogInterface
|
||||
import android.content.DialogInterface.OnShowListener
|
||||
import android.content.Intent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.ColorStateList
|
||||
import android.content.pm.PackageManager
|
||||
@@ -159,10 +162,33 @@ class MainActivity : AppCompatActivity() {
|
||||
startRemoteServer()
|
||||
updateMobileQrOverlay()
|
||||
updateFullScreenQrOverlay()
|
||||
// 网络切换后重新解析当前网卡 MAC,并同步日志上报的 IP/设备标识。
|
||||
DeviceMac.refresh()
|
||||
refreshLogDeviceContext()
|
||||
}
|
||||
main.postDelayed(this, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
/** 全屏右上角扫码点歌二维码显隐控制: 手机连接点歌(页面可见时每秒轮询 state)
|
||||
* 期间自动隐藏, 断开(无请求超过约 4 秒)后恢复显示。 */
|
||||
private val qrAutoHideMonitor = object : Runnable {
|
||||
private var hiddenByClient = false
|
||||
override fun run() {
|
||||
val overlay = fullScreenQrOverlay
|
||||
if (overlay != null && isFullScreen) {
|
||||
val active = remoteServer.hasRecentActivity(4000)
|
||||
if (active && !hiddenByClient) {
|
||||
hiddenByClient = true
|
||||
overlay.visibility = View.GONE
|
||||
} else if (!active && hiddenByClient) {
|
||||
hiddenByClient = false
|
||||
overlay.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
main.postDelayed(this, 1000)
|
||||
}
|
||||
}
|
||||
/** 周期向 hbeat.ktvsky.com 上报设备身份,维持歌曲源对"合法设备"的判定。 */
|
||||
private val ktvHeartbeatRunnable = object : Runnable {
|
||||
override fun run() {
|
||||
@@ -220,6 +246,9 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun run() {
|
||||
updateFullScreenProgress()
|
||||
updateLyricView()
|
||||
// 播放界面左上角的「当前播放 / 下一首」实时刷新: 无论队列是本地点歌、
|
||||
// 手机扫码点歌还是自动切歌而变化, 500ms 内必然同步到界面。
|
||||
refreshNowPlayingLabels()
|
||||
main.postDelayed(this, 500)
|
||||
}
|
||||
}
|
||||
@@ -231,6 +260,8 @@ class MainActivity : AppCompatActivity() {
|
||||
private var queueAdapter: ArrayAdapter<String?>? = null
|
||||
private var audioManager: AudioManager? = null
|
||||
private var recorder: MediaRecorder? = null
|
||||
/** 电视/投影等设备需 App 占用麦克风输入后才会启用麦克风声音输出(供演唱)。 */
|
||||
private val micActivator = MicActivator()
|
||||
private var currentRecording: File? = null
|
||||
private var currentPage = "首页"
|
||||
|
||||
@@ -300,6 +331,7 @@ class MainActivity : AppCompatActivity() {
|
||||
private val browseDisplayRequests = HashMap<BrowseCacheKey, Int>()
|
||||
private var searchScope = "song"
|
||||
private var activeSettingsEntries: List<SettingsEntry> = emptyList()
|
||||
private var activeSettingsAdapter: SettingsListAdapter? = null
|
||||
private var activeSearchQuery = ""
|
||||
private var searchLanguage = "全部"
|
||||
private var searchContextId = ""
|
||||
@@ -344,7 +376,7 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
private var originalVocal = true
|
||||
private var vocalChannelMode = "自动"
|
||||
private var musicVolume = 70
|
||||
private var musicVolume = KtvStore.DEFAULT_MUSIC_VOLUME
|
||||
private var micVolume = 70
|
||||
private var tone = 0
|
||||
private var atmosphere = "标准"
|
||||
@@ -433,8 +465,9 @@ class MainActivity : AppCompatActivity() {
|
||||
// 初始化 API 客户端 (歌曲下载链接)
|
||||
init("abe235a87118f6de", "080027deed4f")
|
||||
playbackEngine = KtvPlaybackEngine(applicationContext)
|
||||
// 远程日志系统: 加载打包证书并后台连接 syslog 服务器, 启动即上报设备/网络信息。
|
||||
// 远程日志系统(GlogCenter): 启动即上报设备/网络信息。
|
||||
MiliLog.init(applicationContext)
|
||||
refreshLogDeviceContext()
|
||||
reportDeviceBootInfo()
|
||||
// 歌曲源窗口/风控到期守卫: 源对陌生出口 IP 可能在短暂窗口后返回 403,
|
||||
// 换公网 IP 即恢复。到期时弹一次提示引导用户重启光猫/路由器(重拨换 IP)。
|
||||
@@ -459,6 +492,10 @@ class MainActivity : AppCompatActivity() {
|
||||
startupSong?.let { restoredSong ->
|
||||
play(restoredSong)
|
||||
updateBottomBar(restoredSong, true)
|
||||
} ?: run {
|
||||
// 启动时没有恢复的播放与已点歌曲: 曲库就绪后自动随机播放本地已下载歌曲
|
||||
// (与"点歌列表播放完毕自动随机本地歌曲"同一逻辑), 避免开机后空白等待。
|
||||
startupRandomPending = true
|
||||
}
|
||||
// Keep initialization on the loading state until the external catalog is ready.
|
||||
SongApiClient.init(this) // 主线程初始化 JS Bridge
|
||||
@@ -466,11 +503,89 @@ class MainActivity : AppCompatActivity() {
|
||||
startRemoteServer()
|
||||
updateMobileQrOverlay()
|
||||
main.postDelayed(remoteNetworkMonitor, 5000)
|
||||
main.postDelayed(qrAutoHideMonitor, 1000)
|
||||
// 设备心跳:开机立即上报一次(boot=1),之后每 3600s 保活,维持歌曲源合法设备判定。
|
||||
sendDeviceHeartbeat(boot = true)
|
||||
main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS)
|
||||
main.post(lyricTicker)
|
||||
main.postDelayed({ checkAppUpdate(false) }, 2500L)
|
||||
// 电视/投影等设备: 请求麦克风权限并保持输入占用, 否则麦克风声音不会输出到扬声器。
|
||||
main.postDelayed({ requestMicActivation() }, 4000L)
|
||||
}
|
||||
|
||||
/** 申请并激活麦克风输入(演唱用)。授权后由 [MicActivator] 常驻占用输入通道。 */
|
||||
private fun requestMicActivation() {
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_MIC_PERMISSION)
|
||||
return
|
||||
}
|
||||
}
|
||||
micActivator.start()
|
||||
registerMicInputWatcher()
|
||||
Log.i(TAG, "麦克风输入已激活(演唱输出通道占用)")
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听输入设备变化: 外接麦克风(USB 声卡/3.5mm 麦)插入时系统会把默认输入路由切到外接,
|
||||
* 原 AudioRecord 随之失效。检测到变化后重建占用, 让演唱输出通道跟随当前麦克风。
|
||||
*/
|
||||
private fun registerMicInputWatcher() {
|
||||
val manager = audioManager ?: return
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
manager.registerAudioDeviceCallback(micDeviceCallback, null)
|
||||
} else {
|
||||
runCatching {
|
||||
val filter = IntentFilter(Intent.ACTION_HEADSET_PLUG)
|
||||
registerReceiver(micHotplugReceiver, filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterMicInputWatcher() {
|
||||
val manager = audioManager ?: return
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
manager.unregisterAudioDeviceCallback(micDeviceCallback)
|
||||
} else {
|
||||
runCatching { unregisterReceiver(micHotplugReceiver) }
|
||||
}
|
||||
}
|
||||
|
||||
private val micDeviceCallback = object : android.media.AudioDeviceCallback() {
|
||||
override fun onAudioDevicesAdded(addedDevices: Array<out android.media.AudioDeviceInfo>) {
|
||||
handleMicInputDevicesChanged(addedDevices.any { isMicInputDevice(it) })
|
||||
}
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out android.media.AudioDeviceInfo>) {
|
||||
handleMicInputDevicesChanged(removedDevices.any { isMicInputDevice(it) })
|
||||
}
|
||||
}
|
||||
|
||||
private val micHotplugReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
handleMicInputDevicesChanged(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否为可用的麦克风输入设备(内置/耳机/USB 等)。 */
|
||||
private fun isMicInputDevice(device: android.media.AudioDeviceInfo): Boolean {
|
||||
if (!device.isSource) return false
|
||||
return when (device.type) {
|
||||
android.media.AudioDeviceInfo.TYPE_BUILTIN_MIC,
|
||||
android.media.AudioDeviceInfo.TYPE_USB_HEADSET,
|
||||
android.media.AudioDeviceInfo.TYPE_USB_DEVICE,
|
||||
android.media.AudioDeviceInfo.TYPE_HDMI,
|
||||
android.media.AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
|
||||
android.media.AudioDeviceInfo.TYPE_LINE_ANALOG,
|
||||
-> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
/** 输入设备增删后: 重新打开麦克风占用, 跟随系统当前路由(优先外接)。 */
|
||||
private fun handleMicInputDevicesChanged(_changed: Boolean) {
|
||||
// 延迟少许等系统完成路由切换, 再重建 AudioRecord。
|
||||
main.postDelayed({ micActivator.restart() }, 300L)
|
||||
}
|
||||
|
||||
private fun initializeDatabase() {
|
||||
@@ -478,14 +593,19 @@ class MainActivity : AppCompatActivity() {
|
||||
databaseBootstrapRunning = true
|
||||
showDatabaseLoading(true, "正在初始化曲库...", null)
|
||||
io.execute {
|
||||
val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion()
|
||||
val localVersion = DatabaseBootstrapper.getLocalDbVersion()
|
||||
val updateRequired = remoteVersion != null && remoteVersion != localVersion
|
||||
var bootstrapError: Throwable? = null
|
||||
var usedOldDatabase = false
|
||||
|
||||
var ok = if (updateRequired) false else library.muse.open()
|
||||
if (!ok) {
|
||||
// 先尝试打开本地曲库;仅当本地不可用或已过 30 天时效时才联网同步。
|
||||
var ok = library.muse.open()
|
||||
val syncDue = DatabaseBootstrapper.isSyncDue()
|
||||
if (!ok || syncDue) {
|
||||
val reason = if (!ok) "本地曲库不可用" else "已到 ${DatabaseBootstrapper.SYNC_TTL_DAYS} 天同步时效"
|
||||
Log.i(TAG, "触发曲库服务器同步:$reason")
|
||||
val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion()
|
||||
if (remoteVersion != null) {
|
||||
val localVersion = DatabaseBootstrapper.getLocalDbVersion()
|
||||
if (!ok || remoteVersion != localVersion) {
|
||||
library.muse.close()
|
||||
val result = DatabaseBootstrapper.download(::showDatabaseDownloadProgress)
|
||||
bootstrapError = result.exceptionOrNull()
|
||||
@@ -495,6 +615,15 @@ class MainActivity : AppCompatActivity() {
|
||||
usedOldDatabase = library.muse.open()
|
||||
ok = usedOldDatabase
|
||||
}
|
||||
} else {
|
||||
// 版本一致:本次已完成有效校验,重置时效起点。
|
||||
DatabaseBootstrapper.markSynced()
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "曲库同步到期,但获取远端版本失败,本次继续使用本地曲库")
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "曲库未到期(${DatabaseBootstrapper.syncStatusText()}),跳过服务器同步")
|
||||
}
|
||||
|
||||
val count = if (ok) library.muse.songCount() else 0
|
||||
@@ -507,6 +636,13 @@ class MainActivity : AppCompatActivity() {
|
||||
showHomePage()
|
||||
if (usedOldDatabase && bootstrapError != null) toast("曲库更新失败,已使用本地曲库")
|
||||
if (catalogRefreshPending) refreshCurrentCatalogPage()
|
||||
// 启动自动随机播放: 仅当启动时无恢复播放/空队列且允许顺序播放时触发一次。
|
||||
if (startupRandomPending) {
|
||||
startupRandomPending = false
|
||||
if (autoNext && orderQueue!!.isEmpty() && currentSong == null) {
|
||||
main.postDelayed({ playRandomLocalSong(startup = true) }, 600L)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val reason = bootstrapError?.message.orEmpty().ifBlank { "未找到可用曲库" }
|
||||
@@ -552,7 +688,10 @@ class MainActivity : AppCompatActivity() {
|
||||
main.removeCallbacks(homeBannerRunnable)
|
||||
main.removeCallbacks(autoFullscreenRunnable)
|
||||
main.removeCallbacks(remoteNetworkMonitor)
|
||||
main.removeCallbacks(qrAutoHideMonitor)
|
||||
stopRecording(false)
|
||||
unregisterMicInputWatcher()
|
||||
micActivator.stop()
|
||||
releaseVocalPlayer()
|
||||
if (::playbackEngine.isInitialized) playbackEngine.release()
|
||||
remoteServer.stop()
|
||||
@@ -1794,7 +1933,9 @@ class MainActivity : AppCompatActivity() {
|
||||
// 关于
|
||||
addSettingsTitle(content, "关于")
|
||||
addSettingsItem(content, "应用名称", "麦动", null)
|
||||
addSettingsItem(content, "版本号", "1.0.0", null)
|
||||
addSettingsItem(content, "框架来源", "杨家三郎/麦动KTV", null)
|
||||
addSettingsItem(content, "内核", "由 DSH 重构数据库源", null)
|
||||
addSettingsItem(content, "版本号", BuildConfig.VERSION_NAME, null)
|
||||
addSettingsItem(
|
||||
content,
|
||||
"曲库",
|
||||
@@ -2282,8 +2423,11 @@ class MainActivity : AppCompatActivity() {
|
||||
val panel = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
// 仅用于吞掉触摸(点击穿透到全屏容器会误切控制层显示), 不是操作项;
|
||||
// 因此禁止焦点/按下填充, 否则触摸时整块面板会变成纯色。
|
||||
isClickable = true
|
||||
}
|
||||
TvFocusStyler.suppressFill(panel)
|
||||
val indicator = CircularVolumeView(this).apply {
|
||||
setVolume(musicVolume)
|
||||
visibility = View.GONE
|
||||
@@ -2333,7 +2477,7 @@ class MainActivity : AppCompatActivity() {
|
||||
indicator.alpha = 1f
|
||||
indicator.visibility = View.VISIBLE
|
||||
renderMuteButton()
|
||||
applySystemMusicVolume()
|
||||
// 只调 App 内部输出音量, 不改系统音量(见 applyPlaybackMode)。
|
||||
applyPlaybackMode()
|
||||
saveState()
|
||||
main.removeCallbacks(hideFullScreenVolumeIndicatorRunnable)
|
||||
@@ -2490,7 +2634,6 @@ class MainActivity : AppCompatActivity() {
|
||||
} else {
|
||||
musicVolume = muteBeforeVol
|
||||
}
|
||||
applySystemMusicVolume()
|
||||
applyPlaybackMode()
|
||||
seekMusicVol.setProgress(musicVolume)
|
||||
tvMusicVol.setText(musicVolume.toString())
|
||||
@@ -2526,7 +2669,6 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onStartTrackingTouch(s: SeekBar?) {}
|
||||
override fun onStopTrackingTouch(s: SeekBar?) {
|
||||
applySystemMusicVolume()
|
||||
applyPlaybackMode()
|
||||
saveState()
|
||||
}
|
||||
@@ -2536,10 +2678,9 @@ class MainActivity : AppCompatActivity() {
|
||||
btnDefault.setOnClickListener(View.OnClickListener { v: View? ->
|
||||
seekMicVol.setProgress(60)
|
||||
seekMicEffect.setProgress(50)
|
||||
seekMusicVol.setProgress(80)
|
||||
seekMusicVol.setProgress(KtvStore.DEFAULT_MUSIC_VOLUME)
|
||||
micVolume = 60
|
||||
musicVolume = 80
|
||||
applySystemMusicVolume()
|
||||
musicVolume = KtvStore.DEFAULT_MUSIC_VOLUME
|
||||
applyPlaybackMode()
|
||||
saveState()
|
||||
tvMicVol.setText("60")
|
||||
@@ -2593,15 +2734,11 @@ class MainActivity : AppCompatActivity() {
|
||||
})
|
||||
root.findViewById<View?>(R.id.btn_mic_down)
|
||||
.setOnClickListener(View.OnClickListener { v: View? ->
|
||||
micVolume = max(0, micVolume - 5)
|
||||
micValue.setText(micVolume.toString())
|
||||
saveState()
|
||||
toast("麦克风音量由功放/混音器硬件调节")
|
||||
})
|
||||
root.findViewById<View?>(R.id.btn_mic_up)
|
||||
.setOnClickListener(View.OnClickListener { v: View? ->
|
||||
micVolume = min(100, micVolume + 5)
|
||||
micValue.setText(micVolume.toString())
|
||||
saveState()
|
||||
toast("麦克风音量由功放/混音器硬件调节")
|
||||
})
|
||||
bindAtmospherePreset(root, R.id.btn_preset_pop, "流行")
|
||||
bindAtmospherePreset(root, R.id.btn_preset_lyric, "抒情")
|
||||
@@ -2951,6 +3088,18 @@ class MainActivity : AppCompatActivity() {
|
||||
val tip = label("手机连接同一局域网后扫码点歌", 18, Color.WHITE)
|
||||
tip.gravity = Gravity.CENTER
|
||||
box.addView(tip, LinearLayout.LayoutParams(dp(380), dp(54)))
|
||||
// 显示当前服务的 ip:端口 地址(去掉路径,便于对照路由器/局域网排查)。
|
||||
val serverHost = remoteServer.address().orEmpty()
|
||||
val serverPort = remoteServer.port()
|
||||
if (serverHost.isNotEmpty() && serverPort > 0) {
|
||||
val addressLine = label("$serverHost:$serverPort", 16, GOLD)
|
||||
addressLine.gravity = Gravity.CENTER
|
||||
box.addView(addressLine, LinearLayout.LayoutParams(dp(380), dp(34)))
|
||||
}
|
||||
// 提示:扫码连上后播放界面右上角二维码会自动隐藏。
|
||||
val hint = label("扫码点歌后自动隐藏播放界面二维码", 14, Color.rgb(193, 193, 193))
|
||||
hint.gravity = Gravity.CENTER
|
||||
box.addView(hint, LinearLayout.LayoutParams(dp(420), dp(30)))
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("扫码点歌")
|
||||
.setView(box)
|
||||
@@ -2995,6 +3144,8 @@ class MainActivity : AppCompatActivity() {
|
||||
bottomMargin = dp(76)
|
||||
}
|
||||
root.addView(box, params)
|
||||
// 二维码浮层是展示型表面: 被焦点填充盖住会直接遮掉二维码。
|
||||
TvFocusStyler.suppressFill(box)
|
||||
mobileQrOverlay = box
|
||||
}
|
||||
|
||||
@@ -3033,6 +3184,8 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
container.addView(box, params)
|
||||
box.bringToFront()
|
||||
// 同手机点歌二维码: 展示型表面不做焦点填充。
|
||||
TvFocusStyler.suppressFill(box)
|
||||
fullScreenQrOverlay = box
|
||||
}
|
||||
|
||||
@@ -3326,16 +3479,16 @@ class MainActivity : AppCompatActivity() {
|
||||
val now = System.currentTimeMillis()
|
||||
val sb = StringBuilder("http://hbeat.ktvsky.com/whoami?_src=600007&_src_at=600001")
|
||||
sb.append("&_m=").append(java.net.URLEncoder.encode(Build.MODEL, "UTF-8"))
|
||||
sb.append("&_mac=").append(LEGACY_DEVICE_ID)
|
||||
sb.append("&_mac=").append(deviceMac())
|
||||
sb.append("&_vc=4&_vn=4.1.3.03281430")
|
||||
sb.append("&_pkn=").append(LEGACY_PACKAGE)
|
||||
sb.append("&_player=0&_boot=").append(if (boot) 1 else 0)
|
||||
sb.append("&_ts=").append(now)
|
||||
sb.append("&_api=").append(Build.VERSION.SDK_INT)
|
||||
sb.append("&ktv_id=").append(LEGACY_KTV_ID)
|
||||
sb.append("&androidid=").append(LEGACY_DEVICE_ID)
|
||||
sb.append("&androidid=").append(deviceMac())
|
||||
sb.append("&_manuf=").append(java.net.URLEncoder.encode(Build.MANUFACTURER, "UTF-8"))
|
||||
sb.append("&_cid=A_").append(LEGACY_DEVICE_ID)
|
||||
sb.append("&_cid=A_").append(deviceMac())
|
||||
sb.append("&d_name=").append(java.net.URLEncoder.encode(Build.PRODUCT, "UTF-8"))
|
||||
sb.append("&d_device=").append(java.net.URLEncoder.encode(Build.DEVICE, "UTF-8"))
|
||||
sb.append("&d_board=").append(java.net.URLEncoder.encode(Build.BOARD, "UTF-8"))
|
||||
@@ -3362,45 +3515,57 @@ class MainActivity : AppCompatActivity() {
|
||||
}, "ktv-heartbeat").start()
|
||||
}
|
||||
|
||||
/** 远程日志: App 启动时上报设备信息与网络信息(异步, 尽力而为)。 */
|
||||
/** 当前设备 MAC(12 位小写十六进制): 本机当前在用网卡, 读不到时回退历史抓包值。 */
|
||||
private fun deviceMac(): String = DeviceMac.effective()
|
||||
|
||||
/** 远程日志: 把设备网卡 IP 与设备标识同步给日志客户端(GlogCenter 的 clientip/user/traceid)。 */
|
||||
private fun refreshLogDeviceContext() {
|
||||
MiliLog.updateDeviceContext(ip = localIp(), deviceId = deviceMac())
|
||||
}
|
||||
|
||||
/** 远程日志: App 启动时上报设备信息(异步, 尽力而为)。 */
|
||||
private fun reportDeviceBootInfo() {
|
||||
Thread({
|
||||
try {
|
||||
val sb = StringBuilder()
|
||||
sb.append("BOOT pkg=").append(packageName)
|
||||
sb.append(" ver=").append(BuildConfig.VERSION_NAME).append('(').append(BuildConfig.VERSION_CODE).append(')')
|
||||
sb.append(" device=").append(Build.BRAND).append(' ').append(Build.MODEL)
|
||||
sb.append(" android=").append(Build.VERSION.RELEASE).append("(api").append(Build.VERSION.SDK_INT).append(')')
|
||||
sb.append(" abi=").append(Build.SUPPORTED_ABIS?.joinToString(","))
|
||||
// 歌曲源设备身份(与抓包请求一致)
|
||||
sb.append(" mac=").append(LEGACY_DEVICE_ID).append(" ktv=").append(LEGACY_KTV_ID)
|
||||
sb.append(" ip=").append(localIp())
|
||||
// 网关 / DNS / WiFi 基础信息
|
||||
try {
|
||||
val wm = getSystemService(WIFI_SERVICE) as android.net.wifi.WifiManager
|
||||
val info = wm.connectionInfo
|
||||
if (info != null) {
|
||||
val ssid = info.ssid?.replace("\"", "")
|
||||
sb.append(" wifi=").append(ssid)
|
||||
sb.append(" rssi=").append(info.rssi)
|
||||
}
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
try {
|
||||
val dm = getSystemService(CONNECTIVITY_SERVICE) as android.net.ConnectivityManager
|
||||
val network = dm.activeNetwork
|
||||
if (network != null) {
|
||||
sb.append(" net=").append(dm.getNetworkCapabilities(network)?.toString().orEmpty())
|
||||
}
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
sb.append(" bootTs=").append(System.currentTimeMillis())
|
||||
sb.append("BOOT")
|
||||
sb.append(" pkg=").append(packageName)
|
||||
sb.append(" ver=").append(BuildConfig.VERSION_NAME)
|
||||
sb.append(" install=").append(installTimeText())
|
||||
sb.append(" brand=").append(Build.BRAND)
|
||||
sb.append(" model=").append(Build.MODEL)
|
||||
sb.append(" device=").append(Build.DEVICE)
|
||||
sb.append(" android=").append(Build.VERSION.RELEASE)
|
||||
.append("(api").append(Build.VERSION.SDK_INT).append(')')
|
||||
sb.append(" abi=").append(Build.SUPPORTED_ABIS?.joinToString(",").orEmpty())
|
||||
sb.append(" kernel=").append(System.getProperty("os.version").orEmpty())
|
||||
sb.append(" screen=").append(screenResolutionText())
|
||||
// 等主机 IP(公网出口 IP)就绪,避免启动这条日志的 serverip 为空。
|
||||
MiliLog.awaitServerIp()
|
||||
MiliLog.notice(sb.toString())
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
}, "ktv-boot-log").start()
|
||||
}
|
||||
|
||||
/** 应用安装时间(yyyy-MM-dd HH:mm:ss); 取不到返回 unknown。 */
|
||||
private fun installTimeText(): String = try {
|
||||
val info = packageManager.getPackageInfo(packageName, 0)
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT).format(Date(info.firstInstallTime))
|
||||
} catch (e: Exception) {
|
||||
"unknown"
|
||||
}
|
||||
|
||||
/** 屏幕物理分辨率(宽x高); 取不到返回 unknown。 */
|
||||
private fun screenResolutionText(): String = try {
|
||||
val metrics = android.util.DisplayMetrics()
|
||||
@Suppress("DEPRECATION")
|
||||
windowManager.defaultDisplay.getRealMetrics(metrics)
|
||||
metrics.widthPixels.toString() + "x" + metrics.heightPixels
|
||||
} catch (e: Exception) {
|
||||
"unknown"
|
||||
}
|
||||
|
||||
private fun remoteApi(
|
||||
method: String,
|
||||
path: String,
|
||||
@@ -3913,6 +4078,8 @@ class MainActivity : AppCompatActivity() {
|
||||
requestPermissions(arrayOf<String>(Manifest.permission.RECORD_AUDIO), 11)
|
||||
return
|
||||
}
|
||||
// MediaRecorder 需要独占麦克风: 先暂停 MicActivator 占用。
|
||||
micActivator.pause()
|
||||
try {
|
||||
val dir = recordDir()
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
@@ -3932,7 +4099,8 @@ class MainActivity : AppCompatActivity() {
|
||||
status!!.setText("正在录音:" + currentRecording!!.getName())
|
||||
if ("首页" == currentPage) showHome()
|
||||
} catch (e: Exception) {
|
||||
stopRecording(false)
|
||||
// 录音启动失败: 释放 recorder 并恢复麦克风输入占用。
|
||||
if (recorder != null) stopRecording(false) else micActivator.resume()
|
||||
toast("录音启动失败:" + e.message)
|
||||
}
|
||||
}
|
||||
@@ -3948,6 +4116,8 @@ class MainActivity : AppCompatActivity() {
|
||||
} catch (ignored: Exception) {
|
||||
}
|
||||
recorder = null
|
||||
// 录音结束 → 恢复麦克风输入占用(演唱声音输出通道)。
|
||||
micActivator.resume()
|
||||
if (notify && currentRecording != null) toast("录音已保存:" + currentRecording!!.getName())
|
||||
currentRecording = null
|
||||
if ("录音" == currentPage) showRecordings()
|
||||
@@ -4159,13 +4329,6 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySystemMusicVolume() {
|
||||
if (audioManager == null) return
|
||||
val max = audioManager!!.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
|
||||
val value = max(0, min(max, (musicVolume * max) / 100))
|
||||
audioManager!!.setStreamVolume(AudioManager.STREAM_MUSIC, value, 0)
|
||||
}
|
||||
|
||||
private fun clamp(value: Int, min: Int, max: Int): Int {
|
||||
return max(min, min(max, value))
|
||||
}
|
||||
@@ -5140,10 +5303,10 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 已点队列自然播放完毕后,从本机所有有效歌曲中随机续播。
|
||||
* 从本机所有有效歌曲中随机续播(启动自动播放 / 点歌列表播放完毕均走此逻辑)。
|
||||
* 随机歌曲不写入已点列表;用户此时点歌会立即取消本次随机请求并优先播放已点歌曲。
|
||||
*/
|
||||
private fun playRandomLocalSong() {
|
||||
private fun playRandomLocalSong(startup: Boolean = false) {
|
||||
val previousSongId = currentSong?.let(::stableId) ?: lastRandomLocalSongId
|
||||
val requestSerial = ++randomLocalRequestSerial
|
||||
playbackPreparing = true
|
||||
@@ -5152,7 +5315,7 @@ class MainActivity : AppCompatActivity() {
|
||||
currentSong = null
|
||||
clearLyrics()
|
||||
updateBottomBar(null, false)
|
||||
status?.text = "已点播放完成,正在随机本地歌曲..."
|
||||
status?.text = if (startup) "正在随机播放本地歌曲..." else "已点播放完成,正在随机本地歌曲..."
|
||||
persistRuntimeState()
|
||||
|
||||
io.execute {
|
||||
@@ -5231,6 +5394,15 @@ class MainActivity : AppCompatActivity() {
|
||||
persistRuntimeState()
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用原伴唱/声道与输出音量。
|
||||
*
|
||||
* 音量语义:`musicVolume`(0-100) 是 **App 内部输出音量**,按线性系数施加到播放器
|
||||
* (`KtvVideoView.setPlaybackVolume` / `selectAudioChannel`) 与副音轨播放器上,
|
||||
* **绝不修改系统音量**(不调用 `AudioManager.setStreamVolume`)。
|
||||
* 播放界面-调音、声音设置-歌曲最大音量、远程控制-音乐音量三处都收敛到这里,
|
||||
* 保证三条入口行为一致且都不影响系统音量。
|
||||
*/
|
||||
private fun applyPlaybackMode() {
|
||||
val videoPlayer = player ?: return
|
||||
videoPlayer.setTone(tone)
|
||||
@@ -6242,6 +6414,8 @@ class MainActivity : AppCompatActivity() {
|
||||
private var fullScreenPreviousVolume = 70
|
||||
private var randomLocalRequestSerial = 0L
|
||||
private var lastRandomLocalSongId: String? = null
|
||||
/** 启动时无恢复播放/空队列: 曲库就绪后自动随机播放本地已下载歌曲(仅触发一次)。 */
|
||||
private var startupRandomPending = false
|
||||
private var advancingPlayback = false
|
||||
private var lastAutoAdvanceAtMs = 0L
|
||||
private var fullScreenChromeVisible = false
|
||||
@@ -6464,6 +6638,8 @@ class MainActivity : AppCompatActivity() {
|
||||
rankCategoryGrid = findViewById(R.id.rank_category_grid)
|
||||
paginationBar = findViewById<View?>(R.id.pagination_bar)
|
||||
fullScreenContainer = findViewById<FrameLayout?>(R.id.container)
|
||||
// 全屏播放容器: 点击用于显隐控制层, 本身是铺满屏幕的表面, 不能有焦点/按下填充。
|
||||
fullScreenContainer?.let { TvFocusStyler.suppressFill(it) }
|
||||
|
||||
// 设置顶栏按钮事件
|
||||
if (btnTopSearch != null) btnTopSearch!!.setOnClickListener(View.OnClickListener { v: View? -> showQuickSearchPage() })
|
||||
@@ -6503,6 +6679,8 @@ class MainActivity : AppCompatActivity() {
|
||||
if (isFullScreen) exitFullScreenPlayer() else showFullScreenPlayer()
|
||||
}
|
||||
}
|
||||
// 视频层本身也是表面型 View, 只做点击进入/退出全屏, 不要焦点填充。
|
||||
TvFocusStyler.suppressFill(persistentVideo)
|
||||
val root = playerLayerRoot
|
||||
if (root != null) {
|
||||
val maskIndex = root.indexOfChild(findViewById<View>(R.id.mask))
|
||||
@@ -6552,6 +6730,10 @@ class MainActivity : AppCompatActivity() {
|
||||
isFocusable = false
|
||||
setOnClickListener { showFullScreenPlayer() }
|
||||
elevation = dp(10).toFloat()
|
||||
// 这层铺满播放画面且浮在最上层: 一旦被套上焦点/按下填充,
|
||||
// 触摸就会把整块播放画面刷成纯色(遮罩层), 必须显式排除。
|
||||
// 它只负责抓触摸, 保持 isFocusable=false, 不参与遥控器焦点。
|
||||
TvFocusStyler.suppressFill(this, focusable = false)
|
||||
}.also { root.addView(it, insertionIndex + 1, FrameLayout.LayoutParams(1, 1)) }
|
||||
}
|
||||
homeVideo = persistentVideo
|
||||
@@ -6598,7 +6780,9 @@ class MainActivity : AppCompatActivity() {
|
||||
homeCardRank, homeCardSong, homeCardSinger, homeCardLocal, homeCardRegular,
|
||||
homeCardFavorite, homeCardCategory,
|
||||
).forEach(::installPressFeedback)
|
||||
listOfNotNull(homePlayerHost, subPagePlayerHost, homePoster).forEach(TvFocusStyler::preserveBackground)
|
||||
// 播放画面宿主/海报是「表面型」View: 不能套焦点/按下填充,
|
||||
// 否则触摸一次就会用纯色层盖住整个播放画面或海报(见 TvFocusStyler.suppressFill)。
|
||||
listOfNotNull(homePlayerHost, subPagePlayerHost, homePoster).forEach(TvFocusStyler::suppressFill)
|
||||
(homePoster as? FrameLayout)?.foreground = getDrawable(R.drawable.bg_player_focus)
|
||||
configureTvFocusNavigation()
|
||||
prepareTvFocusableTree(window.decorView)
|
||||
@@ -6927,33 +7111,39 @@ class MainActivity : AppCompatActivity() {
|
||||
* 同步当前播放信息到首页和子页面的播放器覆盖层
|
||||
*/
|
||||
private fun syncPlayerInfo() {
|
||||
val title = if (currentSong == null) "未播放" else currentSong!!.title
|
||||
val singer = if (currentSong == null) "" else currentSong!!.singer
|
||||
if (homePlayerTitle != null) homePlayerTitle!!.text = currentAndNextText()
|
||||
if (homePlayerSinger != null) homePlayerSinger!!.setText(singer)
|
||||
if (playerSongTitle != null) playerSongTitle!!.setText(title)
|
||||
if (playerSongSinger != null) playerSongSinger!!.setText(singer)
|
||||
// 顶部状态栏 - 当前/下首
|
||||
if (textCurrentSongTip != null) {
|
||||
val cur =
|
||||
"当前: " + title + (if (singer == null || singer.isEmpty()) "" else " - " + singer)
|
||||
val nxt = if (orderQueue!!.size >= 2)
|
||||
orderQueue.get(1).title + (if (orderQueue.get(1).singer == null || orderQueue.get(
|
||||
1
|
||||
).singer!!.isEmpty()
|
||||
) "" else " - " + orderQueue.get(1).singer)
|
||||
else
|
||||
"无"
|
||||
textCurrentSongTip!!.setText(cur + " 下首: " + nxt)
|
||||
}
|
||||
fullScreenSongInfo?.text = currentAndNextText()
|
||||
refreshNowPlayingLabels()
|
||||
updateOrderBadge()
|
||||
if (browseMode == "ordered" || browseMode == "sang" || browseMode == "downloads") {
|
||||
renderSongList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentAndNextText(): String {
|
||||
/**
|
||||
* 实时刷新「当前播放 / 下一首」文本。
|
||||
*
|
||||
* 覆盖播放界面左上角、首页播放条、旧版播放区与顶部状态栏四处; 每次只在文本
|
||||
* 真正变化时才写 View, 因此可以安全地由 `lyricTicker` 每 500ms 调用一次。
|
||||
*/
|
||||
private fun refreshNowPlayingLabels() {
|
||||
val nowNext = nowNextText()
|
||||
val combined = nowNext.combined()
|
||||
if (fullScreenSongInfo?.text?.toString() != combined) fullScreenSongInfo?.text = combined
|
||||
if (homePlayerTitle?.text?.toString() != combined) homePlayerTitle?.text = combined
|
||||
val singer = currentSong?.singer.orEmpty()
|
||||
if (homePlayerSinger?.text?.toString() != singer) homePlayerSinger?.setText(singer)
|
||||
val title = currentSong?.title ?: "未播放"
|
||||
if (playerSongTitle?.text?.toString() != title) playerSongTitle?.setText(title)
|
||||
if (playerSongSinger?.text?.toString() != singer) playerSongSinger?.setText(singer)
|
||||
val tip = "当前: " + nowNext.current + " 下首: " + nowNext.next
|
||||
if (textCurrentSongTip?.text?.toString() != tip) textCurrentSongTip?.setText(tip)
|
||||
}
|
||||
|
||||
/** 「当前播放 / 下一首」文本对(下一首按当前曲目在队列中的位置取其后一首)。 */
|
||||
private data class NowNextText(val current: String, val next: String) {
|
||||
fun combined(): String = "当前:$current 下一首:$next"
|
||||
}
|
||||
|
||||
private fun nowNextText(): NowNextText {
|
||||
val current = currentSong?.let { song ->
|
||||
song.title.orEmpty() + song.singer?.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty()
|
||||
} ?: "未播放"
|
||||
@@ -6964,9 +7154,11 @@ class MainActivity : AppCompatActivity() {
|
||||
else -> orderQueue?.firstOrNull { currentSong?.equals(it) != true }
|
||||
}?.let { song -> song.title.orEmpty() + song.singer?.takeIf { it.isNotBlank() }?.let { " - $it" }.orEmpty() }
|
||||
?: "无"
|
||||
return "当前:$current 下一首:$next"
|
||||
return NowNextText(current, next)
|
||||
}
|
||||
|
||||
private fun currentAndNextText(): String = nowNextText().combined()
|
||||
|
||||
/** 顶栏搜索按钮 - 跳到搜索页 */
|
||||
private fun showSearchPage() {
|
||||
searchScope = "song"
|
||||
@@ -8099,7 +8291,7 @@ class MainActivity : AppCompatActivity() {
|
||||
SettingsEntry(
|
||||
R.drawable.ott_ic_data_upgrade,
|
||||
"应用更新",
|
||||
"当前版本 ${BuildConfig.VERSION_NAME},更新来源:Gitee Release",
|
||||
"当前版本 ${BuildConfig.VERSION_NAME},更新来源:Gitea Release",
|
||||
"检查更新",
|
||||
) { checkAppUpdate(true) },
|
||||
SettingsEntry(R.drawable.ott_ic_about_us, "关于", actionText = "查看") { showAboutDialog() },
|
||||
@@ -8135,8 +8327,14 @@ class MainActivity : AppCompatActivity() {
|
||||
},
|
||||
)
|
||||
1 -> listOf(
|
||||
SettingsEntry(R.drawable.ott_ic_data_upgrade, "曲库", "数据库 ${library.muse.songCount()} 首 本地已下载 ${countDownloadedFiles()} 首", "更新") {
|
||||
io.execute { library.muse.close(); library.muse.open(); main.post { showSettingsSection(1, false) } }
|
||||
SettingsEntry(
|
||||
R.drawable.ott_ic_data_upgrade,
|
||||
"曲库",
|
||||
"统计中…",
|
||||
"更新",
|
||||
) {
|
||||
// 手动强制检查更新(绕过 30 天时效; 仅当服务器版本更新时才下载)
|
||||
forceDatabaseSync()
|
||||
},
|
||||
SettingsEntry(R.drawable.ott_ic_data_setting, "U盘加歌", "扫描U盘内按规定命名的歌曲文件,并添加到曲库中", "立即加歌") { toast("未检测到U盘") },
|
||||
SettingsEntry(R.drawable.ott_ic_setting_storage_space, "预留存储空间", "当前 ${String.format(Locale.ROOT, "%.1f", reserveStorageGb)} GB") { showReserveStorageDialog() },
|
||||
@@ -8164,9 +8362,71 @@ class MainActivity : AppCompatActivity() {
|
||||
songList!!.dividerHeight = dp(12)
|
||||
songList!!.setBackgroundColor(Color.TRANSPARENT)
|
||||
val settingsAdapter = SettingsListAdapter(this, entries, R.id.btn_back)
|
||||
activeSettingsAdapter = settingsAdapter
|
||||
songList!!.adapter = settingsAdapter
|
||||
btnBack?.nextFocusDownId = settingsAdapter.firstActionId
|
||||
if (requestInitialFocus) settingsAdapter.requestInitialFocus(songList!!)
|
||||
// 数据设置: 曲库/下载数统计较耗时(逐文件校验), 移到 IO 线程, 避免进入界面卡顿。
|
||||
if (section == 1) refreshLibraryStatsAsync(settingsAdapter)
|
||||
}
|
||||
|
||||
/** IO 线程统计曲库歌曲数与本地已下载数, 完成后原位回填"曲库"条副标题。 */
|
||||
private fun refreshLibraryStatsAsync(adapter: SettingsListAdapter) {
|
||||
io.execute {
|
||||
val count = runCatching { library.muse.songCount() }.getOrDefault(0)
|
||||
val downloaded = runCatching { countDownloadedFiles() }.getOrDefault(0)
|
||||
// 同步时效(30 天): 未到期不联网, 到期后才触发服务器同步。
|
||||
val syncText = DatabaseBootstrapper.syncStatusText()
|
||||
main.post {
|
||||
if (activeSettingsAdapter === adapter) {
|
||||
adapter.updateSubtitle(
|
||||
"曲库",
|
||||
"数据库 $count 首 本地已下载 $downloaded 首 · ${DatabaseBootstrapper.SYNC_TTL_DAYS} 天同步: $syncText",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动强制检查曲库更新(绕过 30 天时效)。
|
||||
* 仅在服务器版本更新(或本地库不可用)时才下载, 避免无谓的大文件传输。
|
||||
*/
|
||||
private fun forceDatabaseSync() {
|
||||
showDatabaseLoading(true, "正在检查曲库更新...", null)
|
||||
io.execute {
|
||||
val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion()
|
||||
if (remoteVersion == null) {
|
||||
main.post {
|
||||
showDatabaseLoading(false, "", null)
|
||||
toast("检查失败:无法连接曲库服务器")
|
||||
showSettingsSection(1, false)
|
||||
}
|
||||
return@execute
|
||||
}
|
||||
val localVersion = DatabaseBootstrapper.getLocalDbVersion()
|
||||
if (library.muse.isAvailable() && remoteVersion == localVersion) {
|
||||
DatabaseBootstrapper.markSynced()
|
||||
main.post {
|
||||
showDatabaseLoading(false, "", null)
|
||||
toast("曲库已是最新版本")
|
||||
showSettingsSection(1, false)
|
||||
}
|
||||
return@execute
|
||||
}
|
||||
library.muse.close()
|
||||
val result = DatabaseBootstrapper.download(::showDatabaseDownloadProgress)
|
||||
val ok = result.isSuccess && library.muse.open()
|
||||
main.post {
|
||||
if (ok) {
|
||||
showDatabaseLoading(false, "", null)
|
||||
toast("曲库同步完成")
|
||||
showSettingsSection(1, false)
|
||||
} else {
|
||||
showDatabaseLoadingFailure("曲库同步失败:${result.exceptionOrNull()?.message.orEmpty()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isNetworkConnected(): Boolean {
|
||||
@@ -8328,6 +8588,8 @@ class MainActivity : AppCompatActivity() {
|
||||
isFocusable = true
|
||||
elevation = dp(30).toFloat()
|
||||
}
|
||||
// 启动加载遮罩铺满整屏: 不允许被焦点/按下填充覆盖, 否则点击启动界面会整屏变纯色。
|
||||
TvFocusStyler.suppressFill(overlay)
|
||||
val center = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
@@ -8701,7 +8963,8 @@ class MainActivity : AppCompatActivity() {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle("关于麦动")
|
||||
.setMessage(
|
||||
"power by 抖音@杨家三郎\n" +
|
||||
"框架来源:杨家三郎/麦动KTV\n" +
|
||||
"由 DSH 重构数据库源\n" +
|
||||
"此软件免费开源,请勿付费获取\n" +
|
||||
"版本:" + BuildConfig.VERSION_NAME
|
||||
)
|
||||
@@ -9091,6 +9354,13 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
val pageOffset = if (mode == "rank") browsePage * browsePageSize() else 0
|
||||
val existing = modernSongAdapter?.takeIf { it.matches(mode, pageOffset) && list.adapter === it }
|
||||
// 分页网格/排行列表: 行高自适应铺满屏幕(2列目录每页5行, 排行单列每页6行);
|
||||
// 可滚动的队列/下载/已唱列表保持自然行高(rowsPerPage=0)。
|
||||
val rowsPerPage = when (mode) {
|
||||
"catalog" -> 5
|
||||
"rank" -> 6
|
||||
else -> 0
|
||||
}
|
||||
val listAdapter = existing ?: SongListAdapter(
|
||||
this,
|
||||
visibleSongs,
|
||||
@@ -9109,11 +9379,17 @@ class MainActivity : AppCompatActivity() {
|
||||
},
|
||||
onPauseResume = { song -> toggleDownloadPause(song) },
|
||||
),
|
||||
initialRowHeightPx = 0,
|
||||
rowsPerPage = rowsPerPage,
|
||||
).also {
|
||||
modernSongAdapter = it
|
||||
list.adapter = it
|
||||
}
|
||||
if (existing != null) listAdapter.notifyDataSetChanged()
|
||||
// 布局完成后按实际可视高度拉满各行, 消除投影/高分屏底部空白。
|
||||
if (rowsPerPage > 0) {
|
||||
list.post { if (list.adapter === listAdapter) listAdapter.fitRowHeight(list.height) }
|
||||
}
|
||||
|
||||
val targetPosition = focusedMarker?.let(listAdapter::adapterPositionFor)
|
||||
?: oldSelectedPosition.takeIf { it >= 0 }
|
||||
@@ -9566,6 +9842,14 @@ class MainActivity : AppCompatActivity() {
|
||||
recreate()
|
||||
}
|
||||
if (requestCode == 11 && grantResults.size > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) startRecording()
|
||||
if (requestCode == REQUEST_MIC_PERMISSION && grantResults.size > 0 &&
|
||||
grantResults[0] == PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
micActivator.start()
|
||||
Log.i(TAG, "麦克风权限已授予, 麦克风输入激活")
|
||||
} else if (requestCode == REQUEST_MIC_PERMISSION) {
|
||||
Log.w(TAG, "麦克风权限被拒绝: 部分设备将无麦克风演唱声音输出")
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
@@ -9982,9 +10266,10 @@ class MainActivity : AppCompatActivity() {
|
||||
// ===== 歌曲源设备身份(用于 w.w345.my 等歌曲源的保活/认证伪装) =====
|
||||
// 歌曲源通过共享后台按 device 判定合法性, App 会向 hbeat.ktvsky.com
|
||||
// 周期心跳(whoami)维持设备活跃;本 App 若不做心跳,设备可能被判为不合法而限流。
|
||||
// 设备标识与抓包请求一致(mac + sn 见 KtvJsBridge.DEVICE_MAC/DEVICE_SN)。
|
||||
// 设备 MAC 取本机「当前在用网卡」的硬件地址(见 DeviceMac, 读不到时回退历史抓包值);
|
||||
// 抓包时原版 App 的 _mac / androidid / _cid 三者同值, 这里保持该一致性。
|
||||
// sn 见 KtvJsBridge.DEVICE_SN。
|
||||
private const val LEGACY_PACKAGE = "com.origjoy.local.ktv"
|
||||
private const val LEGACY_DEVICE_ID = "ae60e9547958" // androidid / _mac
|
||||
private const val LEGACY_KTV_ID = "10006789" // 门店/包厢 id
|
||||
private const val HEARTBEAT_INTERVAL_MS = 3600_000L
|
||||
|
||||
@@ -10021,6 +10306,7 @@ class MainActivity : AppCompatActivity() {
|
||||
private const val PREFS = "ktv"
|
||||
private const val KEY_CATALOG_URL = "catalog_url"
|
||||
private const val KEY_SHOW_MOBILE_QR = "show_mobile_qr"
|
||||
private const val REQUEST_MIC_PERMISSION = 12
|
||||
|
||||
private const val TAG = "MainActivity"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.local.ktv
|
||||
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* 麦克风输入激活器。
|
||||
*
|
||||
* 背景: 电视/投影盒子等设备, 需要某个应用真正打开(占用)麦克风输入通道后,
|
||||
* 系统/音频 HAL 才会把外接麦克风的声音路由到扬声器(供 KTV 演唱)。仅持有
|
||||
* RECORD_AUDIO 权限而不实际录音是不够的。
|
||||
*
|
||||
* 做法: 用一个低开销的 [AudioRecord] 常驻打开麦克风并循环读取丢弃数据,
|
||||
* 保持输入通道占用(不写文件、不对外暴露声音)。与 MediaRecorder 录音互斥:
|
||||
* 开始录音前先 pause, 结束录音后自动 resume。
|
||||
*
|
||||
* 外接麦克风(USB/耳机麦)热插拔时, 系统会把默认输入路由切到外接设备并使
|
||||
* 原 AudioRecord 失效 —— 此时需重建 AudioRecord 并优先占用当前活动输入设备。
|
||||
* 因此对外暴露 [restart] 供设备变化监听调用; [setPreferredInput] 支持 Android 8.0+
|
||||
* 的 AudioRecord.setPreferredDevice 直接把输入锁定到指定麦克风(优先外接)。
|
||||
*/
|
||||
class MicActivator {
|
||||
private val tag = "MicActivator"
|
||||
private var record: AudioRecord? = null
|
||||
private var thread: Thread? = null
|
||||
@Volatile private var running = false
|
||||
@Volatile private var paused = false
|
||||
|
||||
/** 期望优先使用的输入设备; null = 跟随系统默认(自动切到外接)。 */
|
||||
@Volatile private var preferredInput: AudioDeviceInfo? = null
|
||||
|
||||
val isActive: Boolean get() = running && !paused
|
||||
|
||||
/** 打开麦克风并保持占用。失败静默(设备无麦克风时不影响 App 使用)。 */
|
||||
@Synchronized
|
||||
fun start() {
|
||||
if (running) return
|
||||
running = true
|
||||
paused = false
|
||||
spawnThread()
|
||||
}
|
||||
|
||||
/** 输入设备发生变化(外接麦克风插入/拔出等)时重建 AudioRecord, 跟随新路由。 */
|
||||
@Synchronized
|
||||
fun restart() {
|
||||
if (!running || paused) return
|
||||
releaseInternal()
|
||||
thread?.interrupt()
|
||||
thread = null
|
||||
spawnThread()
|
||||
}
|
||||
|
||||
/** 指定首选输入设备(传入 null 恢复跟随系统默认)。 */
|
||||
@Synchronized
|
||||
fun setPreferredInput(device: AudioDeviceInfo?) {
|
||||
preferredInput = device
|
||||
restart()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun spawnThread() {
|
||||
thread = Thread({ readLoop() }, "mic-activator").also { it.isDaemon = true; it.start() }
|
||||
}
|
||||
|
||||
/** 暂停占用(例如要使用 MediaRecorder 录音时), 保留重开能力。 */
|
||||
@Synchronized
|
||||
fun pause() {
|
||||
if (!running) return
|
||||
paused = true
|
||||
releaseInternal()
|
||||
thread?.interrupt()
|
||||
thread = null
|
||||
}
|
||||
|
||||
/** 恢复占用。 */
|
||||
@Synchronized
|
||||
fun resume() {
|
||||
if (!running) return
|
||||
if (!paused) return
|
||||
paused = false
|
||||
spawnThread()
|
||||
}
|
||||
|
||||
/** 彻底停止(不再自动恢复)。 */
|
||||
@Synchronized
|
||||
fun stop() {
|
||||
running = false
|
||||
paused = false
|
||||
releaseInternal()
|
||||
thread?.interrupt()
|
||||
thread = null
|
||||
}
|
||||
|
||||
private fun readLoop() {
|
||||
var consecutiveFails = 0
|
||||
while (running && !paused) {
|
||||
var recorder: AudioRecord? = null
|
||||
try {
|
||||
val minBuffer = AudioRecord.getMinBufferSize(
|
||||
16000,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
if (minBuffer <= 0) {
|
||||
// 无可用输入配置(可能无麦克风) → 稍后重试。
|
||||
sleepQuietly(5000)
|
||||
continue
|
||||
}
|
||||
recorder = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
16000,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
minBuffer.coerceAtLeast(4096) * 2,
|
||||
)
|
||||
if (recorder.state != AudioRecord.STATE_INITIALIZED) {
|
||||
sleepQuietly(5000)
|
||||
continue
|
||||
}
|
||||
// Android 8.0+: 指定首选输入设备(如刚插入的外接麦克风), 避免仍占用内建。
|
||||
val target = preferredInput
|
||||
if (target != null && android.os.Build.VERSION.SDK_INT >= 26) {
|
||||
runCatching { recorder.setPreferredDevice(target) }
|
||||
}
|
||||
record = recorder
|
||||
recorder.startRecording()
|
||||
val buffer = ByteArray(minBuffer.coerceAtLeast(4096))
|
||||
consecutiveFails = 0
|
||||
while (running && !paused) {
|
||||
// 丢弃读到的数据, 目的仅是让麦克风输入通道保持打开。
|
||||
val read = recorder.read(buffer, 0, buffer.size)
|
||||
if (read < 0) {
|
||||
if (++consecutiveFails > 5) break
|
||||
sleepQuietly(200)
|
||||
} else {
|
||||
consecutiveFails = 0
|
||||
}
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(tag, "mic keep-alive error: ${e.message}")
|
||||
sleepQuietly(5000)
|
||||
} finally {
|
||||
runCatching { recorder?.stop() }
|
||||
runCatching { recorder?.release() }
|
||||
if (record === recorder) record = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseInternal() {
|
||||
val r = record
|
||||
record = null
|
||||
runCatching { r?.stop() }
|
||||
runCatching { r?.release() }
|
||||
}
|
||||
|
||||
private fun sleepQuietly(ms: Long) {
|
||||
try { Thread.sleep(ms) } catch (ignored: InterruptedException) { Thread.currentThread().interrupt() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,122 +3,228 @@ package com.local.ktv
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.io.BufferedOutputStream
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.Calendar
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
|
||||
/**
|
||||
* 远程日志客户端 —— 复刻为内网/私有部署的 syslog 服务器(zb.kasugano.cn:514)。
|
||||
* 远程日志客户端 —— GlogCenter(gotoeasy/glogcenter)HTTP 接口。
|
||||
*
|
||||
* - 传输: TCP + TLS(信任打包的 raw/mili_log.crt 根证书, 服务器为 Let's Encrypt
|
||||
* 签发链 ISRG Root X1, 故该根即可完成链校验; 连接目标域名与证书 SAN 不同,
|
||||
* 属私有部署惯例, 因此只做链校验、不做主机名校验)。
|
||||
* - 格式: BSD syslog RFC 3164 —— <PRI>MMM dd HH:mm:ss HOSTNAME TAG: message
|
||||
* - 策略: 单后台线程 + 有界队列; 断线自动重连; 尽力而为, 失败不阻塞业务。
|
||||
* 接口:`POST http://log.kasugano.cn/glc/v1/log/add`,`Content-Type: application/json`,
|
||||
* 单条 JSON 对象(批量用 `/glc/v1/log/addBatch`,此处按单条上报)。
|
||||
*
|
||||
* 日志事件:
|
||||
* 1) App 启动时上报设备/网络信息;
|
||||
* 2) 点歌解析歌曲源(w.w345.my / 实际下载域名)时实时上报;
|
||||
* 3) 网络错误/接口 403/下载失败/播放失败实时上报。
|
||||
* 字段(均为字符串,对应 GlogCenter 页面列):
|
||||
* system 系统名(本 App 固定 [SYSTEM])
|
||||
* date 日期时间,格式 `yyyy-MM-dd HH:mm:ss.SSS`(务必与检索格式一致)
|
||||
* text 日志内容
|
||||
* servername 主机名(设备型号)
|
||||
* serverip 主机 IP —— 公网出口 IP,由 [PUBLIC_IP_URL] 查询,结果缓存 [PUBLIC_IP_TTL_MS]
|
||||
* clientip 客户端 IP —— 设备当前网卡获取到的局域网 IP(由调用方注入)
|
||||
* loglevel 日志级别(INFO / WARN / ERROR)
|
||||
* traceid 追踪码(此处用设备标识)
|
||||
* user 用户(此处用设备标识)
|
||||
*
|
||||
* 策略:单后台线程 + 有界队列;发送失败重试一次,连续失败退避;尽力而为,不阻塞业务。
|
||||
* 日志事件:启动设备/网络信息、点歌命中的源与结果、接口 403/网络错误、下载/播放失败。
|
||||
*/
|
||||
object MiliLog {
|
||||
private const val TAG = "MiliLog"
|
||||
private const val HOST = "zb.kasugano.cn"
|
||||
private const val PORT = 514
|
||||
private const val TAG_NAME = "MaidongKTV"
|
||||
private const val FACILITY = 16 // local0
|
||||
private const val MAX_MSG_BYTES = 900
|
||||
private const val ENDPOINT = "http://log.kasugano.cn/glc/v1/log/add"
|
||||
|
||||
/** 公网 IP 查询接口(主机 IP)。 */
|
||||
private const val PUBLIC_IP_URL = "https://ipinfo.io/ip"
|
||||
|
||||
/** 公网 IP 缓存时长:5 分钟内复用,避免频繁调用外部接口。 */
|
||||
private const val PUBLIC_IP_TTL_MS = 5 * 60 * 1000L
|
||||
|
||||
/** 公网 IP 查询失败后的重试间隔。 */
|
||||
private const val PUBLIC_IP_RETRY_MS = 60 * 1000L
|
||||
private const val PUBLIC_IP_TIMEOUT_MS = 6000
|
||||
|
||||
/** GlogCenter 页面上的「系统名」,用于区分应用。 */
|
||||
private const val SYSTEM = "maidongktv"
|
||||
private const val MAX_TEXT_LEN = 900
|
||||
private const val MAX_QUEUE = 300
|
||||
private const val CONNECT_TIMEOUT_MS = 8000
|
||||
private const val SOCKET_TIMEOUT_MS = 10000
|
||||
private const val RETRY_SLEEP_MS = 10_000L
|
||||
|
||||
// RFC 3164 severity
|
||||
private const val SEV_ERROR = 3
|
||||
private const val SEV_WARNING = 4
|
||||
private const val SEV_NOTICE = 5
|
||||
private const val SEV_INFO = 6
|
||||
private const val READ_TIMEOUT_MS = 8000
|
||||
private const val RETRY_SLEEP_MS = 1500L
|
||||
private const val FAILURE_BACKOFF_MS = 10_000L
|
||||
|
||||
private val started = AtomicBoolean(false)
|
||||
private val running = AtomicBoolean(false)
|
||||
private val publicIpFetching = AtomicBoolean(false)
|
||||
private val queue = LinkedBlockingQueue<String>(MAX_QUEUE)
|
||||
private var sslContext: SSLContext? = null
|
||||
private var hostname = "android"
|
||||
|
||||
@Volatile private var socket: SSLSocket? = null
|
||||
private var senderThread: Thread? = null
|
||||
|
||||
/** 用 app context 初始化(加载打包证书、读取设备标识)。可重复调用。 */
|
||||
@Volatile private var serverName: String = "android"
|
||||
|
||||
/** 主机 IP:公网出口 IP([PUBLIC_IP_URL] 查询,5 分钟缓存)。 */
|
||||
@Volatile private var serverIp: String = ""
|
||||
@Volatile private var serverIpAt: Long = 0L
|
||||
|
||||
/** 客户端 IP:设备当前网卡获取到的局域网 IP。 */
|
||||
@Volatile private var clientIp: String = ""
|
||||
@Volatile private var traceId: String = ""
|
||||
@Volatile private var user: String = ""
|
||||
|
||||
/** 用 app context 初始化并启动发送线程。可重复调用。 */
|
||||
fun init(context: Context) {
|
||||
if (started.getAndSet(true)) return
|
||||
val appContext = context.applicationContext
|
||||
hostname = sanitize(Build.MODEL).ifEmpty { "android" }
|
||||
sslContext = buildSslContext(appContext)
|
||||
serverName = sanitize(Build.MODEL).ifEmpty { "android" }
|
||||
running.set(true)
|
||||
senderThread = Thread({ sendLoop() }, "mili-syslog").also { it.isDaemon = true; it.start() }
|
||||
senderThread = Thread({ sendLoop() }, "mili-glog").also { it.isDaemon = true; it.start() }
|
||||
ensureServerIp()
|
||||
}
|
||||
|
||||
private fun sanitize(s: String): String =
|
||||
s.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else ' ' }
|
||||
.joinToString("").trim().replace(Regex("\\s+"), "-")
|
||||
|
||||
private fun buildSslContext(context: Context): SSLContext? = try {
|
||||
val cf = CertificateFactory.getInstance("X.509")
|
||||
val cert = context.resources.openRawResource(R.raw.mili_log).use {
|
||||
cf.generateCertificate(it) as X509Certificate
|
||||
/**
|
||||
* 更新上报用的客户端网络/身份字段。
|
||||
*
|
||||
* @param ip 设备当前网卡获取到的局域网 IP(clientip),变化时会顺带复核公网 IP
|
||||
* @param deviceId 设备标识(同时写入 user / traceid)
|
||||
*/
|
||||
fun updateDeviceContext(ip: String? = null, deviceId: String? = null) {
|
||||
if (!ip.isNullOrBlank() && ip != clientIp) {
|
||||
clientIp = ip
|
||||
// 局域网 IP 变化通常意味着换了网络,公网出口 IP 需要立即复核。
|
||||
ensureServerIp(force = true)
|
||||
}
|
||||
if (!deviceId.isNullOrBlank()) {
|
||||
user = deviceId
|
||||
traceId = deviceId
|
||||
}
|
||||
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
|
||||
keyStore.load(null)
|
||||
keyStore.setCertificateEntry("mili_log", cert)
|
||||
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
tmf.init(keyStore)
|
||||
SSLContext.getInstance("TLS").apply { init(null, tmf.trustManagers, null) }
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "buildSslContext failed", e)
|
||||
null
|
||||
}
|
||||
|
||||
// ─── 对外上报接口 ────────────────────────────────
|
||||
fun info(msg: String) = enqueue("INFO", msg)
|
||||
fun notice(msg: String) = enqueue("INFO", msg)
|
||||
fun warn(msg: String) = enqueue("WARN", msg)
|
||||
fun error(msg: String) = enqueue("ERROR", msg)
|
||||
|
||||
fun info(msg: String) = enqueue(SEV_INFO, msg)
|
||||
fun notice(msg: String) = enqueue(SEV_NOTICE, msg)
|
||||
fun warn(msg: String) = enqueue(SEV_WARNING, msg)
|
||||
fun error(msg: String) = enqueue(SEV_ERROR, msg)
|
||||
|
||||
private fun enqueue(severity: Int, msg: String) {
|
||||
if (!started.get() || sslContext == null) return
|
||||
val line = format(severity, msg)
|
||||
if (line == null) return
|
||||
// 队列满时丢最旧, 保证新日志能进队。
|
||||
if (!queue.offer(line)) {
|
||||
private fun enqueue(level: String, msg: String) {
|
||||
if (!started.get()) return
|
||||
// 公网 IP 过期时顺带后台刷新(内部有 5 分钟节流,不会频繁调用外部接口)。
|
||||
ensureServerIp()
|
||||
val body = buildBody(level, msg) ?: return
|
||||
// 队列满时丢最旧,保证新日志能进队。
|
||||
if (!queue.offer(body)) {
|
||||
queue.poll()
|
||||
queue.offer(line)
|
||||
queue.offer(body)
|
||||
}
|
||||
}
|
||||
|
||||
private fun format(severity: Int, msg: String): String? {
|
||||
val body = msg.replace(Regex("[\\r\\n]+"), " ").trim()
|
||||
if (body.isEmpty()) return null
|
||||
val truncated = if (body.toByteArray(Charsets.UTF_8).size > MAX_MSG_BYTES) {
|
||||
body.take(MAX_MSG_BYTES)
|
||||
} else body
|
||||
val now = Calendar.getInstance()
|
||||
val months = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||
val day = String.format(Locale.US, "%2d", now.get(Calendar.DAY_OF_MONTH))
|
||||
val time = String.format(Locale.US, "%02d:%02d:%02d",
|
||||
now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE), now.get(Calendar.SECOND))
|
||||
val pri = FACILITY * 8 + severity
|
||||
// RFC3164: <PRI>TIMESTAMP HOSTNAME TAG: MSG (TAG 不含空格)
|
||||
return "<$pri>${months[now.get(Calendar.MONTH)]} $day $time $hostname $TAG_NAME: $truncated"
|
||||
private fun buildBody(level: String, msg: String): String? {
|
||||
val text = msg.replace(Regex("[\\r\\n]+"), " ").trim()
|
||||
if (text.isEmpty()) return null
|
||||
val truncated = if (text.toByteArray(Charsets.UTF_8).size > MAX_TEXT_LEN) {
|
||||
text.take(MAX_TEXT_LEN)
|
||||
} else {
|
||||
text
|
||||
}
|
||||
return runCatching {
|
||||
JSONObject().apply {
|
||||
put("system", SYSTEM)
|
||||
put("date", timestamp())
|
||||
put("text", truncated)
|
||||
put("servername", serverName)
|
||||
put("serverip", serverIp)
|
||||
put("clientip", clientIp)
|
||||
put("loglevel", level)
|
||||
put("traceid", traceId)
|
||||
put("user", user)
|
||||
}.toString()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/** GlogCenter 检索要求的时间格式:yyyy-MM-dd HH:mm:ss.SSS */
|
||||
private fun timestamp(): String =
|
||||
SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.ROOT).format(Date())
|
||||
|
||||
private fun sanitize(value: String): String =
|
||||
value.map { if (it.isLetterOrDigit() || it == '-' || it == '_' || it == ' ') it else ' ' }
|
||||
.joinToString("").trim()
|
||||
|
||||
// ─── 公网 IP(主机 IP)────────────────────────────
|
||||
|
||||
/**
|
||||
* 确保主机 IP(公网出口 IP)可用。
|
||||
*
|
||||
* 仅在「从未获取过 / 已超过 [PUBLIC_IP_TTL_MS] / 显式要求复核」时发起查询,
|
||||
* 且同一时刻只允许一个查询在飞。查询在独立后台线程完成,绝不阻塞调用方
|
||||
* (`enqueue` 可能来自主线程)。
|
||||
*/
|
||||
private fun ensureServerIp(force: Boolean = false) {
|
||||
val age = System.currentTimeMillis() - serverIpAt
|
||||
val fresh = serverIp.isNotEmpty() && !force && age < PUBLIC_IP_TTL_MS
|
||||
if (fresh) return
|
||||
if (!publicIpFetching.compareAndSet(false, true)) return
|
||||
Thread({
|
||||
try {
|
||||
val ip = queryPublicIp()
|
||||
if (ip.isNotEmpty()) {
|
||||
serverIp = ip
|
||||
serverIpAt = System.currentTimeMillis()
|
||||
} else {
|
||||
// 查询失败:保留旧值,但延后重试,避免每条日志都触发一次请求。
|
||||
serverIpAt = System.currentTimeMillis() - PUBLIC_IP_TTL_MS + PUBLIC_IP_RETRY_MS
|
||||
}
|
||||
} finally {
|
||||
publicIpFetching.set(false)
|
||||
}
|
||||
}, "mili-pubip").also { it.isDaemon = true; it.start() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待主机 IP 就绪(最多 [timeoutMs]),供启动上报使用。
|
||||
*
|
||||
* 调用方必须在子线程(内部会短暂 sleep);超时未就绪则返回空串,不影响上报。
|
||||
*/
|
||||
fun awaitServerIp(timeoutMs: Long = 3000L): String {
|
||||
ensureServerIp()
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (serverIp.isEmpty() && System.currentTimeMillis() < deadline) {
|
||||
try {
|
||||
Thread.sleep(100L)
|
||||
} catch (e: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
break
|
||||
}
|
||||
}
|
||||
return serverIp
|
||||
}
|
||||
|
||||
/** 查询公网出口 IP([PUBLIC_IP_URL] 返回纯文本 IP);失败返回空串。 */
|
||||
private fun queryPublicIp(): String = try {
|
||||
val connection = (URL(PUBLIC_IP_URL).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "GET"
|
||||
connectTimeout = PUBLIC_IP_TIMEOUT_MS
|
||||
readTimeout = PUBLIC_IP_TIMEOUT_MS
|
||||
setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}")
|
||||
}
|
||||
try {
|
||||
val code = connection.responseCode
|
||||
if (code in 200..299) {
|
||||
val text = connection.inputStream.use { it.readBytes().toString(Charsets.UTF_8) }.trim()
|
||||
// 只接受形如 IPv4/IPv6 的纯文本,长度上限 45(IPv6 最长)。
|
||||
if (text.isNotEmpty() && text.length <= 45 &&
|
||||
text.all { it.isLetterOrDigit() || it == '.' || it == ':' }
|
||||
) {
|
||||
text
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "public ip query failed: ${e.message}")
|
||||
""
|
||||
}
|
||||
|
||||
// ─── 发送线程 ────────────────────────────────────
|
||||
@@ -126,76 +232,71 @@ object MiliLog {
|
||||
private fun sendLoop() {
|
||||
var consecutiveFailures = 0
|
||||
while (running.get()) {
|
||||
val line: String = try {
|
||||
val body: String = try {
|
||||
queue.take()
|
||||
} catch (e: InterruptedException) {
|
||||
break
|
||||
}
|
||||
var sent = false
|
||||
var attempt = 0
|
||||
while (!sent && attempt < 2) {
|
||||
attempt++
|
||||
try {
|
||||
val s = connectOrReuse()
|
||||
val out = BufferedOutputStream(s.getOutputStream())
|
||||
out.write(line.toByteArray(Charsets.UTF_8))
|
||||
out.write('\n'.code)
|
||||
out.flush()
|
||||
sent = true
|
||||
var sent = post(body)
|
||||
if (!sent) {
|
||||
sleepQuietly(RETRY_SLEEP_MS)
|
||||
sent = post(body)
|
||||
}
|
||||
if (sent) {
|
||||
consecutiveFailures = 0
|
||||
// 同一连接顺手把队列里已积压的消息发完。
|
||||
// 顺手把已积压的日志发完。
|
||||
var next: String? = queue.poll()
|
||||
while (next != null) {
|
||||
out.write(next.toByteArray(Charsets.UTF_8))
|
||||
out.write('\n'.code)
|
||||
out.flush()
|
||||
post(next)
|
||||
next = queue.poll()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
closeSocket()
|
||||
if (attempt == 1) {
|
||||
Log.w(TAG, "syslog send failed (${e.message}), retrying...")
|
||||
try { Thread.sleep(1500) } catch (ie: InterruptedException) { break }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sent) {
|
||||
} else {
|
||||
consecutiveFailures++
|
||||
Log.w(TAG, "syslog send dropped: $line")
|
||||
Log.w(TAG, "glog send dropped (failures=$consecutiveFailures): ${body.take(120)}")
|
||||
if (consecutiveFailures >= 3) {
|
||||
consecutiveFailures = 0
|
||||
try { Thread.sleep(RETRY_SLEEP_MS) } catch (ie: InterruptedException) { break }
|
||||
sleepQuietly(FAILURE_BACKOFF_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
closeSocket()
|
||||
}
|
||||
|
||||
private fun connectOrReuse(): SSLSocket {
|
||||
val existing = socket
|
||||
if (existing != null && existing.isConnected && !existing.isClosed && !existing.isOutputShutdown) {
|
||||
return existing
|
||||
private fun post(body: String): Boolean = try {
|
||||
val connection = (URL(ENDPOINT).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
setRequestProperty("Content-Type", "application/json; charset=utf-8")
|
||||
setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}")
|
||||
}
|
||||
val context = sslContext ?: throw IllegalStateException("SSL context not ready")
|
||||
val raw = context.socketFactory.createSocket() as SSLSocket
|
||||
raw.connect(java.net.InetSocketAddress(HOST, PORT), CONNECT_TIMEOUT_MS)
|
||||
raw.soTimeout = SOCKET_TIMEOUT_MS
|
||||
raw.startHandshake()
|
||||
socket = raw
|
||||
Log.i(TAG, "syslog connected to $HOST:$PORT")
|
||||
return raw
|
||||
try {
|
||||
connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
||||
val code = connection.responseCode
|
||||
// 读取并丢弃响应体,确保请求完成(也便于排查)。
|
||||
val stream = if (code in 200..299) connection.inputStream else connection.errorStream
|
||||
runCatching { stream?.use { it.readBytes() } }
|
||||
code in 200..299
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "glog send error: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
private fun closeSocket() {
|
||||
try { socket?.close() } catch (ignored: Exception) {}
|
||||
socket = null
|
||||
private fun sleepQuietly(ms: Long) {
|
||||
try {
|
||||
Thread.sleep(ms)
|
||||
} catch (ignored: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
/** 供测试/关闭时调用。 */
|
||||
/** 停止发送(退出时调用)。 */
|
||||
fun shutdown() {
|
||||
running.set(false)
|
||||
senderThread?.interrupt()
|
||||
senderThread = null
|
||||
closeSocket()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import javax.crypto.Cipher
|
||||
/**
|
||||
* 官方曲库同步:login → cookie → sqlite/sync → 下载 gz → 解压安装。
|
||||
*
|
||||
* 这是原版「时光KTV / 麦动」的曲库分发方式,取代此前临时使用的 Gitee 分片方案。
|
||||
* 这是原版「时光KTV / 麦动」的曲库分发方式,取代此前临时使用的分片分发方案。
|
||||
* 授权门控发生在服务端(未授权设备 sync 返回空 files),本地只负责完整复刻请求协议。
|
||||
*/
|
||||
object MuseDbSync {
|
||||
|
||||
@@ -15,7 +15,7 @@ import android.widget.Switch
|
||||
data class SettingsEntry(
|
||||
val icon: Int,
|
||||
val title: String,
|
||||
val subtitle: String = "",
|
||||
var subtitle: String = "",
|
||||
val actionText: String = "设置",
|
||||
val checked: Boolean? = null,
|
||||
val action: () -> Unit,
|
||||
@@ -112,6 +112,16 @@ class SettingsListAdapter(
|
||||
requestFocusAt(list, 0, onTargetReady)
|
||||
}
|
||||
|
||||
/** 原位更新某条目的副标题(如异步统计完成后回填), 不重建列表、不丢焦点。 */
|
||||
fun updateSubtitle(title: String, newSubtitle: String) {
|
||||
val index = entries.indexOfFirst { it.title == title }
|
||||
if (index < 0) return
|
||||
val entry = entries[index]
|
||||
if (entry.subtitle == newSubtitle) return
|
||||
entry.subtitle = newSubtitle
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun positionForMarker(marker: String): Int? {
|
||||
val prefix = "focus:settings:"
|
||||
if (!marker.startsWith(prefix)) return null
|
||||
|
||||
@@ -10,8 +10,8 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
/**
|
||||
* 歌曲 API 客户端 —— JS 热更新桥接
|
||||
* =================================
|
||||
* - 通过 KtvJsBridge 调用 Gitee 上的 JS 脚本
|
||||
* - API 变更时只需更新 Gitee JS 文件,App 无需发版
|
||||
* - 通过 KtvJsBridge 调用 Gitea 上的 JS 脚本
|
||||
* - API 变更时只需更新 Gitea JS 文件,App 无需发版
|
||||
* - 首次播放时实时获取 URL,不存在过期问题
|
||||
*/
|
||||
object SongApiClient {
|
||||
@@ -108,4 +108,8 @@ object SongApiClient {
|
||||
|
||||
@JvmStatic
|
||||
fun clearTokenCache() {}
|
||||
|
||||
/** 最近一次点歌实际命中的歌曲源 host(由 JS 上报, 用于远程日志)。 */
|
||||
@JvmStatic
|
||||
fun lastSongSourceHost(): String = bridge?.lastSongSourceHost.orEmpty()
|
||||
}
|
||||
|
||||
@@ -42,11 +42,32 @@ class SongListAdapter(
|
||||
private val nextPageFocusId: Int,
|
||||
private val stateFor: (Song) -> SongRowState,
|
||||
private val callbacks: SongListCallbacks,
|
||||
private val initialRowHeightPx: Int = 0,
|
||||
private val rowsPerPage: Int = 0,
|
||||
) : BaseAdapter() {
|
||||
private val density = context.resources.displayMetrics.density
|
||||
private val columns = if (mode in SINGLE_COLUMN_MODES) 1 else 2
|
||||
private val cardIds = HashMap<String, Int>()
|
||||
|
||||
/** 当前行高(px)。0 = 保持内容自然高度(滚动列表用)。 */
|
||||
var rowHeightPx: Int = initialRowHeightPx
|
||||
private set
|
||||
|
||||
/**
|
||||
* 让行高铺满可用高度: 把列表可视高度按每页行数均分(分页列表专用)。
|
||||
* 超出 min/max 范围时收敛, 避免过小/过大。滚动列表(rowsPerPage<=0)忽略。
|
||||
*/
|
||||
fun fitRowHeight(availableHeightPx: Int) {
|
||||
if (rowsPerPage <= 0 || availableHeightPx <= 0) return
|
||||
val minPx = dp(52)
|
||||
val maxPx = dp(160)
|
||||
val fitted = (availableHeightPx / rowsPerPage).coerceIn(minPx, maxPx)
|
||||
if (fitted != rowHeightPx) {
|
||||
rowHeightPx = fitted
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private fun cardId(index: Int): Int = cardIds.getOrPut(songKey(songs[index])) { View.generateViewId() }
|
||||
|
||||
override fun getCount(): Int = ceil(songs.size / columns.toDouble()).toInt()
|
||||
@@ -64,28 +85,35 @@ class SongListAdapter(
|
||||
}
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
if (columns == 1) {
|
||||
return createSingleRow(songs[position], position).apply {
|
||||
val view = if (columns == 1) {
|
||||
createSingleRow(songs[position], position).apply {
|
||||
if (position == count - 1) nextFocusDownId = previousPageFocusId
|
||||
}
|
||||
}
|
||||
return LinearLayout(context).apply {
|
||||
} else {
|
||||
LinearLayout(context).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
val cells = mutableListOf<View>()
|
||||
repeat(2) { column ->
|
||||
val index = position * 2 + column
|
||||
val cell = songs.getOrNull(index)?.let {
|
||||
createCatalogCell(it, index, column, position == count - 1)
|
||||
} ?: View(context)
|
||||
cells += cell
|
||||
addView(
|
||||
cell,
|
||||
LinearLayout.LayoutParams(0, dp(60), 1f),
|
||||
LinearLayout.LayoutParams(0, -1, 1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 分页列表: 让整行背景铺满均分行高; 滚动列表保持自然高度。
|
||||
if (rowHeightPx > 0) {
|
||||
view.layoutParams = android.widget.AbsListView.LayoutParams(
|
||||
android.widget.AbsListView.LayoutParams.MATCH_PARENT,
|
||||
rowHeightPx,
|
||||
)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
private fun createSingleRow(song: Song, index: Int): View = when (mode) {
|
||||
"rank" -> createRankRow(song, index)
|
||||
|
||||
@@ -135,8 +135,8 @@ object SongOkDownloadManager {
|
||||
}
|
||||
val musicNo = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls") ?: song.id
|
||||
return SongApiClient.getSongDownloadUrl(musicNo).orEmpty().also { url ->
|
||||
// 请求源 = 配置的歌曲源(与 JS ktv_api.js CONFIG.HOSTS 同步)。
|
||||
val srcHost = "w.w345.my"
|
||||
// 请求源 = 本次点歌实际命中的源(JS 上报); 取不到时留空。
|
||||
val srcHost = SongApiClient.lastSongSourceHost()
|
||||
if (url.isNotEmpty()) {
|
||||
song.downloadUrl = url
|
||||
val cdnHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "unknown"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -97,12 +97,12 @@
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 麦克风 -->
|
||||
<!-- 麦克风(增益/效果由功放混音器硬件调节, 滑杆仅作指示) -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:text="麦克风音量"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:text="麦克风音量(功放/混音器硬件调节)"
|
||||
android:textColor="#FFE0E0FF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
@@ -121,7 +121,8 @@
|
||||
android:max="100"
|
||||
android:progress="60"
|
||||
android:progressTint="#FF27CCA4"
|
||||
android:thumbTint="#FF27CCA4" />
|
||||
android:thumbTint="#FF27CCA4"
|
||||
android:enabled="false" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_mic_volume_value"
|
||||
@@ -136,8 +137,8 @@
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:text="麦克风效果"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:text="麦克风效果(功放/混音器硬件调节)"
|
||||
android:textColor="#FFE0E0FF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
@@ -156,7 +157,8 @@
|
||||
android:max="100"
|
||||
android:progress="50"
|
||||
android:progressTint="#FF27CCA4"
|
||||
android:thumbTint="#FF27CCA4" />
|
||||
android:thumbTint="#FF27CCA4"
|
||||
android:enabled="false" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_mic_effect_value"
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
|
||||
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
|
||||
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
|
||||
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
|
||||
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
|
||||
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
|
||||
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
|
||||
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
|
||||
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
|
||||
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
|
||||
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
|
||||
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
|
||||
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
|
||||
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
|
||||
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
|
||||
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
|
||||
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
|
||||
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
|
||||
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
|
||||
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
|
||||
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
|
||||
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
|
||||
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
|
||||
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
|
||||
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
|
||||
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
|
||||
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
|
||||
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
|
||||
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
|
||||
-----END CERTIFICATE-----
|
||||
Reference in New Issue
Block a user