Compare commits

...
15 Commits
Author SHA1 Message Date
zijunchiang b3b8e6a12d 删除 WX_resized.jpg(本地与仓库, 无引用) 2026-09-12 01:56:38 +08:00
zijunchiang b37dbec443 曲库同步时效 30 天: 未到期启动不联网, 到期后才同步服务器数据库
- DatabaseBootstrapper: 新增 SYNC_TTL_DAYS=30 与 db_sync_at.txt 时间戳
  (lastSyncAt/isSyncDue/syncRemainingMs/syncStatusText/markSynced)
- initializeDatabase: 本地曲库可用且未到期时跳过服务器同步(无网络请求);
  到期或本地不可用才查版本并在有更新时下载
- 设置页: 曲库行显示「30 天同步: N 天后/已到期」; 更新按钮改为手动强制检查
- download(): 成功后记录同步时间(去掉一次多余的远端版本请求)
2026-09-11 23:28:09 +08:00
zijunchiang 4c792361a4 docs(AGENTS): 记录 Gitea Release 发布流程与 ASCII 附件名限制 2026-09-11 23:15:13 +08:00
zijunchiang ba31caaa38 全量迁移到 Gitea: App更新检查/JS热更新端点上链 Gitea, 文档同步
- AppUpdateManager: releases/latest 改用 gitea.kasugano.cn API
- KtvJsBridge: JS 热更新改用 Gitea raw(/raw/branch/master/...)
- MainActivity/SongApiClient/DatabaseBootstrapper/MuseDbSync: 文案与注释更新
- README.md/README.en.md: 标注本仓库地址(Gitea), 保留上游框架归属链接
- AGENTS.md: 运行时端点迁移状态
2026-09-11 23:08:46 +08:00
zijunchiang 21eca10ee2 docs: 同步策略迁移至 Gitea(origin), Gitee 停用并加钩子拦截 2026-09-11 22:57:42 +08:00
zijunchiang 57341280e4 docs: 新增 AGENTS.md 开发约定(禁用自动推送Gitee, 含构建/签名/关键约束) 2026-09-11 02:44:17 +08:00
zijunchiang 3dad59b8de update README.md.
Signed-off-by: zijunchiang <zijunchiang@outlook.com>
2026-09-07 12:18:40 +00:00
zijunchiang 267405d1f3 update README.en.md.
Signed-off-by: zijunchiang <zijunchiang@outlook.com>
2026-09-07 12:17:00 +00:00
zijunchiang 61ad000daf 麦克风: 外接输入设备热插拔跟随重建 + 音量UI改硬件调节提示
- MicActivator: 支持 restart/setPreferredDevice(API26), 输入路由切换后重建占用
- MainActivity: AudioDeviceCallback(API23+)/HEADSET广播监听, USB/外接麦插入后
  自动重建 AudioRecord 跟随当前输入设备
- 麦克风音量标准Android无输入增益API: 远程界面滑杆禁用并提示'由功放/混音器
  硬件调节', 本地调音台+/−按钮同样提示, 避免假调节无效果
2026-09-06 01:33:59 +08:00
zijunchiang 197b01f806 麦克风: 启动请求RECORD_AUDIO权限并常驻占用麦克风输入(TV/投影需激活后才输出演唱声)
- 新增 MicActivator: AudioRecord 低开销常驻打开 MIC 并循环读丢弃, 保持输入通道占用
- MainActivity: onCreate 延迟请求麦克风权限(REQUEST_MIC_PERMISSION=12), 授权后激活
- 与 MediaRecorder 录音互斥: startRecording 前 pause, stopRecording 后 resume
- onDestroy 释放
2026-09-06 00:39:53 +08:00
zijunchiang 9dc0d4b994 扫码点歌优化: 对话框显示ip:端口+隐藏提示; 手机连接时自动隐藏全屏右上角二维码
- showMobileRemoteDialog: 二维码下显示当前服务 ip:端口 与提示文字
- LocalRemoteServer: 记录请求活跃时间 hasRecentActivity()
- MainActivity: qrAutoHideMonitor 每秒检测, 手机连接点歌(4s内有请求)自动隐藏
  全屏右上角二维码, 断开后恢复显示
2026-09-05 23:10:37 +08:00
zijunchiang b5e605bade 版本显示统一为纯 1.2.5(去除 BOOT 日志/旧关于页的括号与写死版本) 2026-09-05 01:51:57 +08:00
zijunchiang ff0ae402ef 优化: 启动自动随机播放本地歌曲 + 数据设置界面卡顿修复
- 启动无恢复播放/空队列时, 曲库就绪后自动随机播放本地已下载歌曲
  (playRandomLocalSong 增加 startup 文案, 与播放完毕随机续播同逻辑, 点歌即取消)
- 数据设置: 曲库/下载数统计(逐文件校验)从主线程移到 IO 线程,
  界面秒开先显示占位, 统计完成后原位回填副标题(updateSubtitle), 不丢焦点
2026-09-05 01:37:58 +08:00
zijunchiang 303e86b475 界面: 歌曲列表行高自适应铺满屏幕(消除投影/高分屏底部空白)
- SongListAdapter: 新增 rowHeightPx/fitRowHeight/rowsPerPage, 分页列表(2列目录5行/排行6行)
  按 ListView 实际可视高度均分行高(clamp 52-160dp), 内容垂直居中, 背景整行铺满
- 滚动型列表(已点/下载/已唱)保持自然行高
- MainActivity.renderSongList: 布局完成后 post fitRowHeight
2026-09-05 00:41:00 +08:00
zijunchiang 2d3751064f 歌曲源: 新增6源(desktok.top/mm.kk456.top/w.w345.my:66/z.w345.my/b.sktv.top/w345.my) + 多源自动排序
- ktv_api.js: HOSTS 扩为 7 源(同后台多入口), 缓存标记 7→8
- 自动排序: 解析失败的源 demote 到队尾, 成功源 promote 到队首(sourceOrder 运行时可变)
- JS→Kotlin 上报实际命中源(reportSongResult), 远程日志请求源不再写死
- SongApiClient/KtvJsBridge: lastSongSourceHost 供日志使用
2026-09-04 23:06:05 +08:00
20 changed files with 800 additions and 119 deletions
+101
View File
@@ -0,0 +1,101 @@
# 麦动 KTV — 项目开发约定
> 本文件供后续开发(含 AI 助手会话)遵循,优先级高于默认习惯。
## 一、代码同步策略(2026-09-11 更新)
**代码主仓库已迁移至 Giteahttps://gitea.kasugano.cn/zijunchiang/maidong-ktv**
| 远端 | 地址 | 状态 |
|---|---|---|
| `origin` | `https://gitea.kasugano.cn/zijunchiang/maidong-ktv.git` | ✅ 主仓库,**同步目标** |
| `gitee` | `https://gitee.com/zijunchiang/maidong-ktv.git` | ⛔ 已停用(推送被钩子拦截) |
- ✅ 预期动作:完成改动 → 本地 `git commit``git push origin master`(同步到 Gitea
- ⛔ 禁止:推送到 Gitee`pre-push` 钩子会拦截,避免误推送)
- 凭据:Gitea 访问令牌 `dsh-sync`(可在 Gitea「设置 → 应用 → 访问令牌」随时吊销),
存放于 `~/.git-credentials-gitea`(权限 600),由本仓库级 `credential.helper` 引用;
账号密码不写入仓库配置。
### 推送方式
```bash
cd /home/zijun/dsh/maidongktv/maidong-ktv
git push origin master # origin = Gitea(主仓库)
git push --no-verify gitee master # 仅在确需回推 Gitee 时使用
```
### 运行时端点(已全部迁移到 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) 创建 Releasetag 需高于当前 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.2.5"` / `versionCode 5`
- 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/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建
- **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403
+3 -2
View File
@@ -59,6 +59,8 @@
## Project Notice ## 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) > **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 > 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: Highlights of the reworked data layer:
- **Library**: synced exclusively from the official Muse service - **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 - **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. 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. - **JS hot-update**: `app/src/main/assets/mobile/ktv_api.js`, requires the `KTV_BRIDGE_API: 7` marker.
## Building ## Building
+4 -3
View File
@@ -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) > **内容框架来源**[https://gitee.com/yangyachao-X/maidong-ktv](https://gitee.com/yangyachao-X/maidong-ktv)
> >
> 本仓库基于上游「麦动 KTV」的界面与功能框架进行改造,并使用 **DSHDeepSeek Harness** 重写内核与数据源:重新实现曲库同步、歌曲源接入、设备认证、风控应对、远程日志与广告屏蔽等底层逻辑。上游框架的版权归原作者所有,本项目仅作学习与技术交流。 > 本仓库基于上游「麦动 KTV」的界面与功能框架进行改造,并使用 **DSHDeepSeek Harness** 重写内核与数据源:重新实现曲库同步、歌曲源接入、设备认证、风控应对、远程日志与广告屏蔽等底层逻辑。上游框架的版权归原作者所有,本项目仅作学习与技术交流。
@@ -46,12 +48,11 @@
## 内核与数据源(DSH 重写部分) ## 内核与数据源(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 不一致)会被拦截并重试。 - **歌曲源**:接入 `w.w345.my``i.php` 取 token → `do.php` 解析播放地址),固定设备标识并复刻签名算法;广告/错号资源(如返回 `wb66.cn/.../719.ts` 或文件名与 musicno 不一致)会被拦截并重试。
- **风控应对**:源对陌生出口 IP 存在试用窗口(约 10 分钟,403"检测到异常行为"),App 检测到 403 后弹窗提示更换公网 IP;同 token 退避重试,不放大请求量。 - **风控应对**:源对陌生出口 IP 存在试用窗口(约 10 分钟,403"检测到异常行为"),App 检测到 403 后弹窗提示更换公网 IP;同 token 退避重试,不放大请求量。
- **远程日志**:可选的 syslog 客户端(RFC 3164TCP+TLS`zb.kasugano.cn:514`),启动上报设备/网络信息,点歌实时记录「编号 / 标题 / 请求源 / 状态」。
- **JS 热更新**:歌曲接口逻辑在 `app/src/main/assets/mobile/ktv_api.js`,安装后从本仓库自动拉取兼容版本(须保留 `KTV_BRIDGE_API: 7` 标记)。 - **JS 热更新**:歌曲接口逻辑在 `app/src/main/assets/mobile/ktv_api.js`,安装后从本仓库自动拉取兼容版本(须保留 `KTV_BRIDGE_API: 7` 标记)。
- **应用更新**:以 Gitee Release 标签作为版本号(如 `1.2.5`),Release 需附带一个 `.apk` 附件;仅当标签高于当前 `BuildConfig.VERSION_NAME` 时提示下载。 - **应用更新**:以 Gitea Release 标签作为版本号(如 `1.2.5`),Release 需附带一个 `.apk` 附件;仅当标签高于当前 `BuildConfig.VERSION_NAME` 时提示下载。
## 🔧 编译教程 ## 🔧 编译教程
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

+2
View File
@@ -15,3 +15,5 @@
.rank-pager button:disabled{opacity:.35;box-shadow:none} .rank-pager button:disabled{opacity:.35;box-shadow:none}
.rank-pager b{min-width:64px;text-align:center;color:#b9a9c9;font-size:11px} .rank-pager b{min-width:64px;text-align:center;color:#b9a9c9;font-size:11px}
.empty.compact{padding:42px 15px} .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)}
+6 -5
View File
@@ -75,7 +75,9 @@ async function refresh() {
$("#musicVolume").value = data.musicVolume ?? 70; $("#musicVolume").value = data.musicVolume ?? 70;
$("#micVolume").value = data.micVolume ?? 70; $("#micVolume").value = data.micVolume ?? 70;
$("#musicValue").textContent = data.musicVolume ?? 70; $("#musicValue").textContent = data.musicVolume ?? 70;
$("#micValue").textContent = data.micVolume ?? 70; // 麦克风音量由功放/混音器硬件调节(滑杆已禁用), 仅保留显示值。
const micHw = $("#micValue");
if (micHw) micHw.textContent = data.micVolume ?? 70;
$("#original").classList.toggle("active", data.vocalMode === "original"); $("#original").classList.toggle("active", data.vocalMode === "original");
$("#accompany").classList.toggle("active", data.vocalMode !== "original"); $("#accompany").classList.toggle("active", data.vocalMode !== "original");
$("#nextSong").textContent = data.next?.title || "队列为空"; $("#nextSong").textContent = data.next?.title || "队列为空";
@@ -203,10 +205,9 @@ $("#searchForm").addEventListener("submit", (event) => { event.preventDefault();
$("#more").addEventListener("click", () => search(false)); $("#more").addEventListener("click", () => search(false));
$("#hotPrev").addEventListener("click", () => loadHot(uiState.hotPage - 1)); $("#hotPrev").addEventListener("click", () => loadHot(uiState.hotPage - 1));
$("#hotNext").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; }); $("#musicVolume").addEventListener("input", (event) => { $("#musicValue").textContent = event.target.value; });
$("#" + kind + "Volume").addEventListener("change", (event) => updateAudio({ [kind + "Volume"]: Number(event.target.value) })); $("#musicVolume").addEventListener("change", (event) => updateAudio({ musicVolume: Number(event.target.value) }));
});
document.addEventListener("visibilitychange", () => { uiState.visible = !document.hidden; if (uiState.visible) refresh(); }); document.addEventListener("visibilitychange", () => { uiState.visible = !document.hidden; if (uiState.visible) refresh(); });
window.setInterval(() => { if (uiState.visible) refresh(); }, 1000); window.setInterval(() => { if (uiState.visible) refresh(); }, 1000);
refresh(); refresh();
+1 -1
View File
@@ -37,7 +37,7 @@
<div class="panel-title"><div><span class="tiny-icon"></span><b>声音控制</b></div><small>即时生效</small></div> <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> <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"></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 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>
<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> <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>
+91 -31
View File
@@ -1,7 +1,7 @@
/** /**
* KTV Song API - JS Hot Update Module * KTV Song API - JS Hot Update Module
* ==================================== * ====================================
* 从 Gitee 热更新, App 无需发版即可修复 API 变更 * 从 Gitea 热更新, App 无需发版即可修复 API 变更
* *
* 用法: * 用法:
* const api = new KtvApi({proxy: 'http://127.0.0.1:7897'}) * const api = new KtvApi({proxy: 'http://127.0.0.1:7897'})
@@ -12,7 +12,7 @@
(function(global) { (function(global) {
'use strict'; 'use strict';
// KTV_BRIDGE_API: 7 (Android 缓存校验标记,请勿删除) // KTV_BRIDGE_API: 8 (Android 缓存校验标记,请勿删除)
// ─── 配置 ────────────────────────────────────────── // ─── 配置 ──────────────────────────────────────────
const CONFIG = { const CONFIG = {
@@ -21,8 +21,16 @@
SDK_KEY: '19042303a8374f67ae3fe1e25c97936f', SDK_KEY: '19042303a8374f67ae3fe1e25c97936f',
VN: '4.100.5.11121504', VN: '4.100.5.11121504',
VER: '4', 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', HOST: 'http://w.w345.my',
MWS: 'https://mws.cherryonline.cn', MWS: 'https://mws.cherryonline.cn',
RSA_PUBKEY: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAucL0oFErd7REM6TSNa3EZdN1YaOs4J1eCybLPyoQ9ru3q1HU67agC9FzhrCG/RvAQUya5iPmQ8Caed05vqcCVcyJChmkSOGQ7DVShe2rGuTMNlpoRV6UzfcraaVS++7m2K/+kSZJ8OAhhhVuqPruMjsFYpdtstAwvyZT28b+eENwzpp9UHqsooZc7FZ0H8kTbs6XMkw4nIWo+4HoPAhNLEY+xdHvwY6drF/3WDTvsaoMrs73TVQCEEHzZNIz2H/is9VLMnIyOfnfcJi9br78Fj2xHzxu3sAySBOTVLmUMxqYh/g1ox5OXGcW93HJkQLkBi42tFAEkWYlYyl93+jbbQIDAQAB' RSA_PUBKEY: 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAucL0oFErd7REM6TSNa3EZdN1YaOs4J1eCybLPyoQ9ru3q1HU67agC9FzhrCG/RvAQUya5iPmQ8Caed05vqcCVcyJChmkSOGQ7DVShe2rGuTMNlpoRV6UzfcraaVS++7m2K/+kSZJ8OAhhhVuqPruMjsFYpdtstAwvyZT28b+eENwzpp9UHqsooZc7FZ0H8kTbs6XMkw4nIWo+4HoPAhNLEY+xdHvwY6drF/3WDTvsaoMrs73TVQCEEHzZNIz2H/is9VLMnIyOfnfcJi9br78Fj2xHzxu3sAySBOTVLmUMxqYh/g1ox5OXGcW93HJkQLkBi42tFAEkWYlYyl93+jbbQIDAQAB'
@@ -142,6 +150,15 @@
return false; 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) { async function httpGet(url, timeout) {
timeout = timeout || 8000; timeout = timeout || 8000;
// 原 APK 的 native GET 抓包只带 Accept: */*,不伪造来源 IP。 // 原 APK 的 native GET 抓包只带 Accept: */*,不伪造来源 IP。
@@ -185,6 +202,9 @@
this.mac = options.mac || randomHex(16); this.mac = options.mac || randomHex(16);
this.sn = options.sn || randomHex(16); this.sn = options.sn || randomHex(16);
this.tokens = {}; this.tokens = {};
// 歌曲源自动排序: 维护一份"当前优先级"列表(运行时可变)。
// 某源解析失败时把它挪到队尾, 下次点歌优先尝试更可靠的源。
this.sourceOrder = (options.hosts && options.hosts.length ? options.hosts : CONFIG.HOSTS).slice();
// 与 test_ktv_unified.py 一致:首次设备加最多 3 次换设备重试。 // 与 test_ktv_unified.py 一致:首次设备加最多 3 次换设备重试。
this.maxDeviceRetries = Number.isFinite(Number(options.maxDeviceRetries)) this.maxDeviceRetries = Number.isFinite(Number(options.maxDeviceRetries))
? Math.max(0, Number(options.maxDeviceRetries) | 0) : 3; ? Math.max(0, Number(options.maxDeviceRetries) | 0) : 3;
@@ -196,6 +216,26 @@
KtvApi.prototype = { KtvApi.prototype = {
_log: function(msg) { if (this.debug) console.log('[KtvApi]', msg); }, _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() { regenerateDevice: function() {
this.mac = randomHex(16); this.mac = randomHex(16);
this.sn = randomHex(16); this.sn = randomHex(16);
@@ -299,69 +339,89 @@
}, },
// 3. Get Song Download URL // 3. Get Song Download URL
// 当前仅使用 w.w345.my(与 e.ac19.cn 同后台的另一入口, 只对 ls=1 返回真源)。 // 多源自动排序: 按 this.sourceOrder(当前优先级)依次尝试; 某源解析失败
// 结构保留 host/device 循环, 便于日后恢复多节点/备用源。 // (token 失败 / do.php 非 200 / 广告错号 / 无有效 URL)时立即 demote 到队尾,
// 下次点歌自动优先使用更可靠的源。全部源失败返回 null。
getSongUrl: async function(musicno, resolution, h265, _preferredLs) { getSongUrl: async function(musicno, resolution, h265, _preferredLs) {
resolution = resolution || '720'; resolution = resolution || '720';
h265 = !!h265; h265 = !!h265;
var preferredLs = String(_preferredLs == null ? '1' : _preferredLs);
var self = this; var self = this;
var lastDetail = '';
var lastAttemptHost = '';
for (var deviceRound = 0; deviceRound <= self.maxDeviceRetries; deviceRound++) { for (var deviceRound = 0; deviceRound <= self.maxDeviceRetries; deviceRound++) {
if (deviceRound > 0) self.regenerateDevice(); if (deviceRound > 0) self.regenerateDevice();
for (var hostIndex = 0; hostIndex < CONFIG.HOSTS.length; hostIndex++) { // 每一轮使用当时的优先级快照; demote 动态修改 sourceOrder。
var host = CONFIG.HOSTS[hostIndex]; var order = self.sourceOrder.slice();
self._log((hostIndex === 0 ? 'Primary' : 'Fallback') + for (var hostIndex = 0; hostIndex < order.length; hostIndex++) {
' host, device round ' + (deviceRound + 1) + ': ' + host); var host = order[hostIndex];
lastAttemptHost = host;
self._log('Try source (' + (hostIndex + 1) + '/' + order.length + '): ' + host);
var token = await self.getToken(host, false); 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=1(其余 ls 值会被其风控直接拒绝, 反复试会放大请求量)。
// 反复试会放大请求量)。若未来恢复多节点/备用源, 需按节点保留 ls 轮询。
var lsValues = ['1']; 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]; var ls = lsValues[lsi];
try { try {
var data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265); var data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265);
// 抓包证实: i.php 下发的 token 恒定不变(端到端同一值, 不随 time/请求变化), // token 恒定不变; do.php 403"检测到异常行为"是风控限流, 与 token 过期无关。
// do.php 返回 403"检测到异常行为"是风控限流, 与 token 过期无关 // 403/失败/广告命中时同 token 短退避重试一次, 不再清 token 重取 i.php
// 原版遇 403 不重取 token, 而是同 token 间隔几秒重试数次后停手(等风控窗口 // 广告/错号资源(如 wb66 719.ts)也算失败, 退避重试一次等真源。
// 过去再成功)。因此这里: 403/失败/广告命中时同 token 短退避重试一次,
// 不再清 token 重取 i.php(那会多打一倍请求, 反而放大限流)。
// 任务4: do.php code=200 但返回广告/错号资源(如 wb66 719.ts)也算失败,
// 退避重试一次, 给服务端机会返回与 musicno 一致的真源。
var needRetry = data.code !== 200; var needRetry = data.code !== 200;
if (!needRetry && data.code === 200 && isDemoUrl(data.data || '', musicno)) { if (!needRetry && data.code === 200 && isDemoUrl(data.data || '', musicno)) {
self._log('广告/错号资源 (ls=' + ls + '): ' + String(data.data).slice(0, 90)); self._log('广告/错号资源 (ls=' + ls + '): ' + String(data.data).slice(0, 90));
needRetry = true; needRetry = true;
} }
if (needRetry) { if (needRetry) {
self._log('do.php code=' + data.code + ' http=' + (data.httpStatus || 0) + hostDetail = 'do.php code=' + data.code + ' http=' + (data.httpStatus || 0);
' for ' + musicno + ' ls=' + ls + '; retry once with same token'); self._log(hostDetail + ' for ' + musicno + ' ls=' + ls + '; retry once with same token');
await sleep(3000); await sleep(3000);
data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265); data = await self._fetchSongUrl(host, musicno, token, ls, resolution, h265);
} }
if (data.code === 200) { if (data.code === 200) {
var songUrl = data.data || ''; var songUrl = data.data || '';
// 任务4: 广告/错误资源屏蔽——返回的文件编号与请求不一致即视为广告 // 任务4: 返回的文件编号与请求不一致即视为广告/错误资源, 一律丢弃。
// (例: 请求 7018878 却返回 719.ts / wb66 demo 等), 一律丢弃。
if (songUrl && !isDemoUrl(songUrl, musicno) && /^https?:\/\//i.test(songUrl)) { if (songUrl && !isDemoUrl(songUrl, musicno) && /^https?:\/\//i.test(songUrl)) {
self._log('URL OK: ' + musicno + ' ls=' + ls + ' via ' + host); self._log('URL OK: ' + musicno + ' ls=' + ls + ' via ' + host);
self.promoteSource(host);
hostOk = true;
// 任务3: 点歌成功上报命中的源(Android 侧写入远程日志)。
reportSongResult(true, host, musicno, 'ok');
return songUrl; return songUrl;
} }
if (isDemoUrl(songUrl, musicno)) if (isDemoUrl(songUrl, musicno)) {
self._log('仍为广告/错号资源 (ls=' + ls + '): ' + String(songUrl).slice(0, 90)); hostDetail = '仍为广告/错号资源 (ls=' + ls + ')';
// 不是 demo 但也无效 (空/格式不对) → 继续试下一个 ls self._log(hostDetail + ': ' + String(songUrl).slice(0, 90));
}
} }
} catch(e) { } catch(e) {
hostDetail = e.message;
self._log('Failed ls=' + ls + ': ' + host + ', ' + 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; return null;
}, },
@@ -375,7 +435,7 @@
// ─── 导出 ────────────────────────────────────────── // ─── 导出 ──────────────────────────────────────────
global.KtvApi = KtvApi; global.KtvApi = KtvApi;
global.KtvApiConfig = CONFIG; global.KtvApiConfig = CONFIG;
global.KtvBridgeApiVersion = 7; global.KtvBridgeApiVersion = 8;
if (typeof module !== 'undefined' && module.exports) { if (typeof module !== 'undefined' && module.exports) {
module.exports = {KtvApi: KtvApi, CONFIG: CONFIG}; module.exports = {KtvApi: KtvApi, CONFIG: CONFIG};
@@ -11,10 +11,10 @@ import java.io.FileOutputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
/** Gitee Release based in-app updater. */ /** Gitea Release based in-app updater. */
object AppUpdateManager { object AppUpdateManager {
private const val LATEST_RELEASE_API = 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( data class Release(
val version: String, val version: String,
@@ -54,7 +54,7 @@ object AppUpdateManager {
apkName = apkName, apkName = apkName,
) )
}) })
}, "gitee-update-check").start() }, "gitea-update-check").start()
} }
fun download( fun download(
@@ -105,7 +105,7 @@ object AppUpdateManager {
require(partial.renameTo(target)) { "无法保存安装包" } require(partial.renameTo(target)) { "无法保存安装包" }
target target
}) })
}, "gitee-update-download").start() }, "gitea-update-download").start()
} }
/** /**
@@ -7,14 +7,57 @@ import java.util.Locale
/** /**
* 曲库下载引导(仅官方 Muse 同步源)。 * 曲库下载引导(仅官方 Muse 同步源)。
* *
* 说明:已移除 Gitee 数据库分片/镜像下载路径(database_publish 不再发布到 Gitee * 说明:已移除分片/镜像数据库下载路径(database_publish 不再发布到代码仓库
* Gitee 仓库此后只存代码)。曲库统一从官方 Muse 服务(mconn.cherryonline.cn mls-api * 代码仓库此后只存代码)。曲库统一从官方 Muse 服务(mconn.cherryonline.cn mls-api
* 同步,由 [MuseDbSync] 负责登录、同步、校验与安装;本类只做版本文件与可读错误包装。 * 同步,由 [MuseDbSync] 负责登录、同步、校验与安装;本类负责同步时效、版本文件
* 与可读错误包装。
*
* 同步时效:默认 **30 天**[SYNC_TTL_DAYS])。未到期时启动不联网同步,直接用
* 本地曲库;到期后才向服务器查询版本并在有更新时下载。
*/ */
object DatabaseBootstrapper { object DatabaseBootstrapper {
private const val TAG = "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 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。 */ /** 远端曲库版本(官方 Muse);失败返回 null。 */
fun fetchRemoteVersion(): String? = MuseDbSync.fetchVersion() fun fetchRemoteVersion(): String? = MuseDbSync.fetchVersion()
@@ -23,16 +66,12 @@ object DatabaseBootstrapper {
versionFile.takeIf { it.exists() }?.readText()?.trim()?.takeIf { it.isNotEmpty() } versionFile.takeIf { it.exists() }?.readText()?.trim()?.takeIf { it.isNotEmpty() }
}.getOrNull() }.getOrNull()
private fun saveLocalDbVersion(version: String) { /** 下载曲库:仅官方 Muse 同步源;成功后记录同步时间(重置 30 天时效)。 */
runCatching { versionFile.writeText(version.trim()) }
}
/** 下载曲库:仅官方 Muse 同步源。 */
fun download(onProgress: (Int) -> Unit): Result<File> { fun download(onProgress: (Int) -> Unit): Result<File> {
return runCatching { return runCatching {
val file = MuseDbSync.download(onProgress) val file = MuseDbSync.download(onProgress)
// MuseDbSync 内部已保存版本;此处幂等兜底 // MuseDbSync 内部已保存版本文件;这里记录同步时间作为时效起点
MuseDbSync.fetchVersion()?.let(::saveLocalDbVersion) markSynced()
file file
}.mapError(::readableError) }.mapError(::readableError)
} }
+26 -5
View File
@@ -30,15 +30,16 @@ import javax.crypto.Cipher
* 在隐藏 WebView 中运行可热更新的歌曲接口脚本。 * 在隐藏 WebView 中运行可热更新的歌曲接口脚本。
* *
* JS 决定接口地址、参数、签名和响应解析;Android 只提供 HTTP 与 RSA 这两个 * JS 决定接口地址、参数、签名和响应解析;Android 只提供 HTTP 与 RSA 这两个
* WebView 中不可靠的底层能力。这样接口变化时只需更新 Gitee 上的 ktv_api.js。 * WebView 中不可靠的底层能力。这样接口变化时只需更新 Gitea 上的 ktv_api.js。
*/ */
class KtvJsBridge(context: Context) { class KtvJsBridge(context: Context) {
companion object { companion object {
private const val TAG = "KtvJsBridge" private const val TAG = "KtvJsBridge"
private const val JS_FILE = "mobile/ktv_api.js" private const val JS_FILE = "mobile/ktv_api.js"
private const val CACHE_FILE = "ktv_api_cache.js" private const val CACHE_FILE = "ktv_api_cache.js"
// 热更新脚本地址(Gitea 主仓库; raw 规范路径为 /raw/branch/<分支>/<文件>)
private const val REMOTE_JS_URL = 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 MAX_RESPONSE_BYTES = 2 * 1024 * 1024
private const val JS_REQUEST_TIMEOUT_MS = 180_000 private const val JS_REQUEST_TIMEOUT_MS = 180_000
// 当前歌曲源 w.w345.my 使用的设备标识(mac + sn)。与用户抓包示例一致, // 当前歌曲源 w.w345.my 使用的设备标识(mac + sn)。与用户抓包示例一致,
@@ -57,6 +58,11 @@ class KtvJsBridge(context: Context) {
private var loadedFromCache = false private var loadedFromCache = false
private var assetRetryAttempted = false private var assetRetryAttempted = false
/** 最近一次点歌实际命中的歌曲源 host(由 JS reportSongResult 更新, 供远程日志)。 */
@Volatile
var lastSongSourceHost: String = ""
private set
/** 必须最终在主线程创建 WebView;可从任意线程调用。 */ /** 必须最终在主线程创建 WebView;可从任意线程调用。 */
fun init(callback: (Boolean) -> Unit) { fun init(callback: (Boolean) -> Unit) {
mainHandler.post { mainHandler.post {
@@ -212,7 +218,7 @@ class KtvJsBridge(context: Context) {
runCatching { runCatching {
file.isFile && file.length() in 1_001..500_000 && file.isFile && file.length() in 1_001..500_000 &&
file.readText(Charsets.UTF_8).let { 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) }.getOrDefault(false)
@@ -238,14 +244,14 @@ class KtvJsBridge(context: Context) {
val script = String(bytes, StandardCharsets.UTF_8) val script = String(bytes, StandardCharsets.UTF_8)
require( require(
script.length > 1000 && script.contains("KtvApi") && script.length > 1000 && script.contains("KtvApi") &&
script.contains("KTV_BRIDGE_API: 7"), script.contains("KTV_BRIDGE_API: 8"),
) { "Invalid or incompatible JS file" } ) { "Invalid or incompatible JS file" }
tempFile.writeText(script, Charsets.UTF_8) tempFile.writeText(script, Charsets.UTF_8)
if (!tempFile.renameTo(cacheFile)) { if (!tempFile.renameTo(cacheFile)) {
cacheFile.writeText(script, Charsets.UTF_8) cacheFile.writeText(script, Charsets.UTF_8)
tempFile.delete() 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 { mainHandler.post {
if (ready && callbacks.isEmpty() && !destroyed) { if (ready && callbacks.isEmpty() && !destroyed) {
@@ -296,6 +302,21 @@ class KtvJsBridge(context: Context) {
fun httpPost(url: String, body: String, headersJson: String, timeoutMs: Int): String = fun httpPost(url: String, body: String, headersJson: String, timeoutMs: Int): String =
request("POST", url, body, headersJson, timeoutMs) 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 { private fun request(method: String, url: String, body: String?, headersJson: String, timeoutMs: Int): String {
// 歌曲源(如 w.w345.my)的 do.php 风控只放行"干净"请求(原版仅 Host+Accept; // 歌曲源(如 w.w345.my)的 do.php 风控只放行"干净"请求(原版仅 Host+Accept;
// 带任何 UA/Connection/Accept-Encoding 头都会被拒)。裸 Socket 只发 Host+JS 头。 // 带任何 UA/Connection/Accept-Encoding 头都会被拒)。裸 Socket 只发 Host+JS 头。
@@ -39,6 +39,7 @@ class LocalRemoteServer(private val context: Context) {
@Volatile private var boundAddress: String? = null @Volatile private var boundAddress: String? = null
@Volatile private var boundPort = -1 @Volatile private var boundPort = -1
@Volatile private var lastError: String? = null @Volatile private var lastError: String? = null
@Volatile private var lastActivityAtMs = 0L
private var server: ServerSocket? = null private var server: ServerSocket? = null
private var callback: Callback? = null private var callback: Callback? = null
@@ -98,6 +99,13 @@ class LocalRemoteServer(private val context: Context) {
fun port(): Int = boundPort fun port(): Int = boundPort
fun address(): String? = boundAddress fun address(): String? = boundAddress
fun error(): String? = lastError 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) { fun url(): String? = if (running && boundAddress != null && boundPort > 0) {
"http://$boundAddress:$boundPort/mobile" "http://$boundAddress:$boundPort/mobile"
} else null } else null
@@ -142,6 +150,8 @@ class LocalRemoteServer(private val context: Context) {
val path = target.substringBefore('?') val path = target.substringBefore('?')
val query = parseQuery(target.substringAfter('?', "")) val query = parseQuery(target.substringAfter('?', ""))
val body = String(bodyBytes, 0, offset, Charsets.UTF_8) val body = String(bodyBytes, 0, offset, Charsets.UTF_8)
// 手机端已成功建立会话并访问页面/接口 → 记录活跃(用于控制二维码显隐)。
lastActivityAtMs = System.currentTimeMillis()
val response = when { val response = when {
path == "/" || path == "/mobile" || path == "/mobile/" -> asset("mobile/index.html", "text/html; charset=utf-8") 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") path == "/mobile/app.css" -> asset("mobile/app.css", "text/css; charset=utf-8")
+265 -28
View File
@@ -5,6 +5,9 @@ import android.app.AlertDialog
import android.content.DialogInterface import android.content.DialogInterface
import android.content.DialogInterface.OnShowListener import android.content.DialogInterface.OnShowListener
import android.content.Intent import android.content.Intent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.IntentFilter
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.content.pm.PackageManager import android.content.pm.PackageManager
@@ -163,6 +166,26 @@ class MainActivity : AppCompatActivity() {
main.postDelayed(this, 5000) 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 上报设备身份,维持歌曲源对"合法设备"的判定。 */ /** 周期向 hbeat.ktvsky.com 上报设备身份,维持歌曲源对"合法设备"的判定。 */
private val ktvHeartbeatRunnable = object : Runnable { private val ktvHeartbeatRunnable = object : Runnable {
override fun run() { override fun run() {
@@ -231,6 +254,8 @@ class MainActivity : AppCompatActivity() {
private var queueAdapter: ArrayAdapter<String?>? = null private var queueAdapter: ArrayAdapter<String?>? = null
private var audioManager: AudioManager? = null private var audioManager: AudioManager? = null
private var recorder: MediaRecorder? = null private var recorder: MediaRecorder? = null
/** 电视/投影等设备需 App 占用麦克风输入后才会启用麦克风声音输出(供演唱)。 */
private val micActivator = MicActivator()
private var currentRecording: File? = null private var currentRecording: File? = null
private var currentPage = "首页" private var currentPage = "首页"
@@ -300,6 +325,7 @@ class MainActivity : AppCompatActivity() {
private val browseDisplayRequests = HashMap<BrowseCacheKey, Int>() private val browseDisplayRequests = HashMap<BrowseCacheKey, Int>()
private var searchScope = "song" private var searchScope = "song"
private var activeSettingsEntries: List<SettingsEntry> = emptyList() private var activeSettingsEntries: List<SettingsEntry> = emptyList()
private var activeSettingsAdapter: SettingsListAdapter? = null
private var activeSearchQuery = "" private var activeSearchQuery = ""
private var searchLanguage = "全部" private var searchLanguage = "全部"
private var searchContextId = "" private var searchContextId = ""
@@ -459,6 +485,10 @@ class MainActivity : AppCompatActivity() {
startupSong?.let { restoredSong -> startupSong?.let { restoredSong ->
play(restoredSong) play(restoredSong)
updateBottomBar(restoredSong, true) updateBottomBar(restoredSong, true)
} ?: run {
// 启动时没有恢复的播放与已点歌曲: 曲库就绪后自动随机播放本地已下载歌曲
// (与"点歌列表播放完毕自动随机本地歌曲"同一逻辑), 避免开机后空白等待。
startupRandomPending = true
} }
// Keep initialization on the loading state until the external catalog is ready. // Keep initialization on the loading state until the external catalog is ready.
SongApiClient.init(this) // 主线程初始化 JS Bridge SongApiClient.init(this) // 主线程初始化 JS Bridge
@@ -466,11 +496,89 @@ class MainActivity : AppCompatActivity() {
startRemoteServer() startRemoteServer()
updateMobileQrOverlay() updateMobileQrOverlay()
main.postDelayed(remoteNetworkMonitor, 5000) main.postDelayed(remoteNetworkMonitor, 5000)
main.postDelayed(qrAutoHideMonitor, 1000)
// 设备心跳:开机立即上报一次(boot=1),之后每 3600s 保活,维持歌曲源合法设备判定。 // 设备心跳:开机立即上报一次(boot=1),之后每 3600s 保活,维持歌曲源合法设备判定。
sendDeviceHeartbeat(boot = true) sendDeviceHeartbeat(boot = true)
main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS) main.postDelayed(ktvHeartbeatRunnable, HEARTBEAT_INTERVAL_MS)
main.post(lyricTicker) main.post(lyricTicker)
main.postDelayed({ checkAppUpdate(false) }, 2500L) 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() { private fun initializeDatabase() {
@@ -478,23 +586,37 @@ class MainActivity : AppCompatActivity() {
databaseBootstrapRunning = true databaseBootstrapRunning = true
showDatabaseLoading(true, "正在初始化曲库...", null) showDatabaseLoading(true, "正在初始化曲库...", null)
io.execute { io.execute {
val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion()
val localVersion = DatabaseBootstrapper.getLocalDbVersion()
val updateRequired = remoteVersion != null && remoteVersion != localVersion
var bootstrapError: Throwable? = null var bootstrapError: Throwable? = null
var usedOldDatabase = false var usedOldDatabase = false
var ok = if (updateRequired) false else library.muse.open() // 先尝试打开本地曲库;仅当本地不可用或已过 30 天时效时才联网同步。
if (!ok) { var ok = library.muse.open()
library.muse.close() val syncDue = DatabaseBootstrapper.isSyncDue()
val result = DatabaseBootstrapper.download(::showDatabaseDownloadProgress) if (!ok || syncDue) {
bootstrapError = result.exceptionOrNull() val reason = if (!ok) "本地曲库不可用" else "已到 ${DatabaseBootstrapper.SYNC_TTL_DAYS} 天同步时效"
ok = result.isSuccess && library.muse.open() Log.i(TAG, "触发曲库服务器同步:$reason")
if (!ok) { val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion()
// A failed online update must not make an existing local catalog unusable. if (remoteVersion != null) {
usedOldDatabase = library.muse.open() val localVersion = DatabaseBootstrapper.getLocalDbVersion()
ok = usedOldDatabase if (!ok || remoteVersion != localVersion) {
library.muse.close()
val result = DatabaseBootstrapper.download(::showDatabaseDownloadProgress)
bootstrapError = result.exceptionOrNull()
ok = result.isSuccess && library.muse.open()
if (!ok) {
// A failed online update must not make an existing local catalog unusable.
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 val count = if (ok) library.muse.songCount() else 0
@@ -507,6 +629,13 @@ class MainActivity : AppCompatActivity() {
showHomePage() showHomePage()
if (usedOldDatabase && bootstrapError != null) toast("曲库更新失败,已使用本地曲库") if (usedOldDatabase && bootstrapError != null) toast("曲库更新失败,已使用本地曲库")
if (catalogRefreshPending) refreshCurrentCatalogPage() if (catalogRefreshPending) refreshCurrentCatalogPage()
// 启动自动随机播放: 仅当启动时无恢复播放/空队列且允许顺序播放时触发一次。
if (startupRandomPending) {
startupRandomPending = false
if (autoNext && orderQueue!!.isEmpty() && currentSong == null) {
main.postDelayed({ playRandomLocalSong(startup = true) }, 600L)
}
}
} }
} else { } else {
val reason = bootstrapError?.message.orEmpty().ifBlank { "未找到可用曲库" } val reason = bootstrapError?.message.orEmpty().ifBlank { "未找到可用曲库" }
@@ -552,7 +681,10 @@ class MainActivity : AppCompatActivity() {
main.removeCallbacks(homeBannerRunnable) main.removeCallbacks(homeBannerRunnable)
main.removeCallbacks(autoFullscreenRunnable) main.removeCallbacks(autoFullscreenRunnable)
main.removeCallbacks(remoteNetworkMonitor) main.removeCallbacks(remoteNetworkMonitor)
main.removeCallbacks(qrAutoHideMonitor)
stopRecording(false) stopRecording(false)
unregisterMicInputWatcher()
micActivator.stop()
releaseVocalPlayer() releaseVocalPlayer()
if (::playbackEngine.isInitialized) playbackEngine.release() if (::playbackEngine.isInitialized) playbackEngine.release()
remoteServer.stop() remoteServer.stop()
@@ -1794,7 +1926,7 @@ class MainActivity : AppCompatActivity() {
// 关于 // 关于
addSettingsTitle(content, "关于") addSettingsTitle(content, "关于")
addSettingsItem(content, "应用名称", "麦动", null) addSettingsItem(content, "应用名称", "麦动", null)
addSettingsItem(content, "版本号", "1.0.0", null) addSettingsItem(content, "版本号", BuildConfig.VERSION_NAME, null)
addSettingsItem( addSettingsItem(
content, content,
"曲库", "曲库",
@@ -2593,15 +2725,11 @@ class MainActivity : AppCompatActivity() {
}) })
root.findViewById<View?>(R.id.btn_mic_down) root.findViewById<View?>(R.id.btn_mic_down)
.setOnClickListener(View.OnClickListener { v: View? -> .setOnClickListener(View.OnClickListener { v: View? ->
micVolume = max(0, micVolume - 5) toast("麦克风音量由功放/混音器硬件调节")
micValue.setText(micVolume.toString())
saveState()
}) })
root.findViewById<View?>(R.id.btn_mic_up) root.findViewById<View?>(R.id.btn_mic_up)
.setOnClickListener(View.OnClickListener { v: View? -> .setOnClickListener(View.OnClickListener { v: View? ->
micVolume = min(100, micVolume + 5) toast("麦克风音量由功放/混音器硬件调节")
micValue.setText(micVolume.toString())
saveState()
}) })
bindAtmospherePreset(root, R.id.btn_preset_pop, "流行") bindAtmospherePreset(root, R.id.btn_preset_pop, "流行")
bindAtmospherePreset(root, R.id.btn_preset_lyric, "抒情") bindAtmospherePreset(root, R.id.btn_preset_lyric, "抒情")
@@ -2951,6 +3079,18 @@ class MainActivity : AppCompatActivity() {
val tip = label("手机连接同一局域网后扫码点歌", 18, Color.WHITE) val tip = label("手机连接同一局域网后扫码点歌", 18, Color.WHITE)
tip.gravity = Gravity.CENTER tip.gravity = Gravity.CENTER
box.addView(tip, LinearLayout.LayoutParams(dp(380), dp(54))) 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) AlertDialog.Builder(this)
.setTitle("扫码点歌") .setTitle("扫码点歌")
.setView(box) .setView(box)
@@ -3368,7 +3508,7 @@ class MainActivity : AppCompatActivity() {
try { try {
val sb = StringBuilder() val sb = StringBuilder()
sb.append("BOOT pkg=").append(packageName) sb.append("BOOT pkg=").append(packageName)
sb.append(" ver=").append(BuildConfig.VERSION_NAME).append('(').append(BuildConfig.VERSION_CODE).append(')') sb.append(" ver=").append(BuildConfig.VERSION_NAME)
sb.append(" device=").append(Build.BRAND).append(' ').append(Build.MODEL) 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(" android=").append(Build.VERSION.RELEASE).append("(api").append(Build.VERSION.SDK_INT).append(')')
sb.append(" abi=").append(Build.SUPPORTED_ABIS?.joinToString(",")) sb.append(" abi=").append(Build.SUPPORTED_ABIS?.joinToString(","))
@@ -3913,6 +4053,8 @@ class MainActivity : AppCompatActivity() {
requestPermissions(arrayOf<String>(Manifest.permission.RECORD_AUDIO), 11) requestPermissions(arrayOf<String>(Manifest.permission.RECORD_AUDIO), 11)
return return
} }
// MediaRecorder 需要独占麦克风: 先暂停 MicActivator 占用。
micActivator.pause()
try { try {
val dir = recordDir() val dir = recordDir()
if (!dir.exists()) dir.mkdirs() if (!dir.exists()) dir.mkdirs()
@@ -3932,7 +4074,8 @@ class MainActivity : AppCompatActivity() {
status!!.setText("正在录音:" + currentRecording!!.getName()) status!!.setText("正在录音:" + currentRecording!!.getName())
if ("首页" == currentPage) showHome() if ("首页" == currentPage) showHome()
} catch (e: Exception) { } catch (e: Exception) {
stopRecording(false) // 录音启动失败: 释放 recorder 并恢复麦克风输入占用。
if (recorder != null) stopRecording(false) else micActivator.resume()
toast("录音启动失败:" + e.message) toast("录音启动失败:" + e.message)
} }
} }
@@ -3948,6 +4091,8 @@ class MainActivity : AppCompatActivity() {
} catch (ignored: Exception) { } catch (ignored: Exception) {
} }
recorder = null recorder = null
// 录音结束 → 恢复麦克风输入占用(演唱声音输出通道)。
micActivator.resume()
if (notify && currentRecording != null) toast("录音已保存:" + currentRecording!!.getName()) if (notify && currentRecording != null) toast("录音已保存:" + currentRecording!!.getName())
currentRecording = null currentRecording = null
if ("录音" == currentPage) showRecordings() if ("录音" == currentPage) showRecordings()
@@ -5140,10 +5285,10 @@ class MainActivity : AppCompatActivity() {
} }
/** /**
* 已点队列自然播放完毕后从本机所有有效歌曲中随机续播 * 从本机所有有效歌曲中随机续播(启动自动播放 / 点歌列表播放完毕均走此逻辑)
* 随机歌曲不写入已点列表用户此时点歌会立即取消本次随机请求并优先播放已点歌曲 * 随机歌曲不写入已点列表用户此时点歌会立即取消本次随机请求并优先播放已点歌曲
*/ */
private fun playRandomLocalSong() { private fun playRandomLocalSong(startup: Boolean = false) {
val previousSongId = currentSong?.let(::stableId) ?: lastRandomLocalSongId val previousSongId = currentSong?.let(::stableId) ?: lastRandomLocalSongId
val requestSerial = ++randomLocalRequestSerial val requestSerial = ++randomLocalRequestSerial
playbackPreparing = true playbackPreparing = true
@@ -5152,7 +5297,7 @@ class MainActivity : AppCompatActivity() {
currentSong = null currentSong = null
clearLyrics() clearLyrics()
updateBottomBar(null, false) updateBottomBar(null, false)
status?.text = "已点播放完成,正在随机本地歌曲..." status?.text = if (startup) "正在随机播放本地歌曲..." else "已点播放完成,正在随机本地歌曲..."
persistRuntimeState() persistRuntimeState()
io.execute { io.execute {
@@ -6242,6 +6387,8 @@ class MainActivity : AppCompatActivity() {
private var fullScreenPreviousVolume = 70 private var fullScreenPreviousVolume = 70
private var randomLocalRequestSerial = 0L private var randomLocalRequestSerial = 0L
private var lastRandomLocalSongId: String? = null private var lastRandomLocalSongId: String? = null
/** 启动时无恢复播放/空队列: 曲库就绪后自动随机播放本地已下载歌曲(仅触发一次)。 */
private var startupRandomPending = false
private var advancingPlayback = false private var advancingPlayback = false
private var lastAutoAdvanceAtMs = 0L private var lastAutoAdvanceAtMs = 0L
private var fullScreenChromeVisible = false private var fullScreenChromeVisible = false
@@ -8099,7 +8246,7 @@ class MainActivity : AppCompatActivity() {
SettingsEntry( SettingsEntry(
R.drawable.ott_ic_data_upgrade, R.drawable.ott_ic_data_upgrade,
"应用更新", "应用更新",
"当前版本 ${BuildConfig.VERSION_NAME},更新来源:Gitee Release", "当前版本 ${BuildConfig.VERSION_NAME},更新来源:Gitea Release",
"检查更新", "检查更新",
) { checkAppUpdate(true) }, ) { checkAppUpdate(true) },
SettingsEntry(R.drawable.ott_ic_about_us, "关于", actionText = "查看") { showAboutDialog() }, SettingsEntry(R.drawable.ott_ic_about_us, "关于", actionText = "查看") { showAboutDialog() },
@@ -8135,8 +8282,14 @@ class MainActivity : AppCompatActivity() {
}, },
) )
1 -> listOf( 1 -> listOf(
SettingsEntry(R.drawable.ott_ic_data_upgrade, "曲库", "数据库 ${library.muse.songCount()} 首 本地已下载 ${countDownloadedFiles()}", "更新") { SettingsEntry(
io.execute { library.muse.close(); library.muse.open(); main.post { showSettingsSection(1, false) } } R.drawable.ott_ic_data_upgrade,
"曲库",
"统计中…",
"更新",
) {
// 手动强制检查更新(绕过 30 天时效; 仅当服务器版本更新时才下载)
forceDatabaseSync()
}, },
SettingsEntry(R.drawable.ott_ic_data_setting, "U盘加歌", "扫描U盘内按规定命名的歌曲文件,并添加到曲库中", "立即加歌") { toast("未检测到U盘") }, 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() }, SettingsEntry(R.drawable.ott_ic_setting_storage_space, "预留存储空间", "当前 ${String.format(Locale.ROOT, "%.1f", reserveStorageGb)} GB") { showReserveStorageDialog() },
@@ -8164,9 +8317,71 @@ class MainActivity : AppCompatActivity() {
songList!!.dividerHeight = dp(12) songList!!.dividerHeight = dp(12)
songList!!.setBackgroundColor(Color.TRANSPARENT) songList!!.setBackgroundColor(Color.TRANSPARENT)
val settingsAdapter = SettingsListAdapter(this, entries, R.id.btn_back) val settingsAdapter = SettingsListAdapter(this, entries, R.id.btn_back)
activeSettingsAdapter = settingsAdapter
songList!!.adapter = settingsAdapter songList!!.adapter = settingsAdapter
btnBack?.nextFocusDownId = settingsAdapter.firstActionId btnBack?.nextFocusDownId = settingsAdapter.firstActionId
if (requestInitialFocus) settingsAdapter.requestInitialFocus(songList!!) 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 { private fun isNetworkConnected(): Boolean {
@@ -9091,6 +9306,13 @@ class MainActivity : AppCompatActivity() {
} }
val pageOffset = if (mode == "rank") browsePage * browsePageSize() else 0 val pageOffset = if (mode == "rank") browsePage * browsePageSize() else 0
val existing = modernSongAdapter?.takeIf { it.matches(mode, pageOffset) && list.adapter === it } 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( val listAdapter = existing ?: SongListAdapter(
this, this,
visibleSongs, visibleSongs,
@@ -9109,11 +9331,17 @@ class MainActivity : AppCompatActivity() {
}, },
onPauseResume = { song -> toggleDownloadPause(song) }, onPauseResume = { song -> toggleDownloadPause(song) },
), ),
initialRowHeightPx = 0,
rowsPerPage = rowsPerPage,
).also { ).also {
modernSongAdapter = it modernSongAdapter = it
list.adapter = it list.adapter = it
} }
if (existing != null) listAdapter.notifyDataSetChanged() if (existing != null) listAdapter.notifyDataSetChanged()
// 布局完成后按实际可视高度拉满各行, 消除投影/高分屏底部空白。
if (rowsPerPage > 0) {
list.post { if (list.adapter === listAdapter) listAdapter.fitRowHeight(list.height) }
}
val targetPosition = focusedMarker?.let(listAdapter::adapterPositionFor) val targetPosition = focusedMarker?.let(listAdapter::adapterPositionFor)
?: oldSelectedPosition.takeIf { it >= 0 } ?: oldSelectedPosition.takeIf { it >= 0 }
@@ -9566,6 +9794,14 @@ class MainActivity : AppCompatActivity() {
recreate() recreate()
} }
if (requestCode == 11 && grantResults.size > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) startRecording() 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 { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@@ -10021,6 +10257,7 @@ class MainActivity : AppCompatActivity() {
private const val PREFS = "ktv" private const val PREFS = "ktv"
private const val KEY_CATALOG_URL = "catalog_url" private const val KEY_CATALOG_URL = "catalog_url"
private const val KEY_SHOW_MOBILE_QR = "show_mobile_qr" private const val KEY_SHOW_MOBILE_QR = "show_mobile_qr"
private const val REQUEST_MIC_PERMISSION = 12
private const val TAG = "MainActivity" 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() }
}
}
@@ -20,7 +20,7 @@ import javax.crypto.Cipher
/** /**
* 官方曲库同步:login → cookie → sqlite/sync → 下载 gz → 解压安装。 * 官方曲库同步:login → cookie → sqlite/sync → 下载 gz → 解压安装。
* *
* 这是原版「时光KTV / 麦动」的曲库分发方式,取代此前临时使用的 Gitee 分片方案。 * 这是原版「时光KTV / 麦动」的曲库分发方式,取代此前临时使用的分片分发方案。
* 授权门控发生在服务端(未授权设备 sync 返回空 files),本地只负责完整复刻请求协议。 * 授权门控发生在服务端(未授权设备 sync 返回空 files),本地只负责完整复刻请求协议。
*/ */
object MuseDbSync { object MuseDbSync {
@@ -15,7 +15,7 @@ import android.widget.Switch
data class SettingsEntry( data class SettingsEntry(
val icon: Int, val icon: Int,
val title: String, val title: String,
val subtitle: String = "", var subtitle: String = "",
val actionText: String = "设置", val actionText: String = "设置",
val checked: Boolean? = null, val checked: Boolean? = null,
val action: () -> Unit, val action: () -> Unit,
@@ -112,6 +112,16 @@ class SettingsListAdapter(
requestFocusAt(list, 0, onTargetReady) 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? { fun positionForMarker(marker: String): Int? {
val prefix = "focus:settings:" val prefix = "focus:settings:"
if (!marker.startsWith(prefix)) return null if (!marker.startsWith(prefix)) return null
@@ -10,8 +10,8 @@ import java.util.concurrent.atomic.AtomicReference
/** /**
* 歌曲 API 客户端 —— JS 热更新桥接 * 歌曲 API 客户端 —— JS 热更新桥接
* ================================= * =================================
* - 通过 KtvJsBridge 调用 Gitee 上的 JS 脚本 * - 通过 KtvJsBridge 调用 Gitea 上的 JS 脚本
* - API 变更时只需更新 Gitee JS 文件,App 无需发版 * - API 变更时只需更新 Gitea JS 文件,App 无需发版
* - 首次播放时实时获取 URL,不存在过期问题 * - 首次播放时实时获取 URL,不存在过期问题
*/ */
object SongApiClient { object SongApiClient {
@@ -108,4 +108,8 @@ object SongApiClient {
@JvmStatic @JvmStatic
fun clearTokenCache() {} fun clearTokenCache() {}
/** 最近一次点歌实际命中的歌曲源 host(由 JS 上报, 用于远程日志)。 */
@JvmStatic
fun lastSongSourceHost(): String = bridge?.lastSongSourceHost.orEmpty()
} }
@@ -42,11 +42,32 @@ class SongListAdapter(
private val nextPageFocusId: Int, private val nextPageFocusId: Int,
private val stateFor: (Song) -> SongRowState, private val stateFor: (Song) -> SongRowState,
private val callbacks: SongListCallbacks, private val callbacks: SongListCallbacks,
private val initialRowHeightPx: Int = 0,
private val rowsPerPage: Int = 0,
) : BaseAdapter() { ) : BaseAdapter() {
private val density = context.resources.displayMetrics.density private val density = context.resources.displayMetrics.density
private val columns = if (mode in SINGLE_COLUMN_MODES) 1 else 2 private val columns = if (mode in SINGLE_COLUMN_MODES) 1 else 2
private val cardIds = HashMap<String, Int>() 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() } private fun cardId(index: Int): Int = cardIds.getOrPut(songKey(songs[index])) { View.generateViewId() }
override fun getCount(): Int = ceil(songs.size / columns.toDouble()).toInt() override fun getCount(): Int = ceil(songs.size / columns.toDouble()).toInt()
@@ -64,27 +85,34 @@ class SongListAdapter(
} }
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
if (columns == 1) { val view = if (columns == 1) {
return createSingleRow(songs[position], position).apply { createSingleRow(songs[position], position).apply {
if (position == count - 1) nextFocusDownId = previousPageFocusId if (position == count - 1) nextFocusDownId = previousPageFocusId
} }
} } else {
return LinearLayout(context).apply { LinearLayout(context).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL gravity = Gravity.CENTER_VERTICAL
val cells = mutableListOf<View>() repeat(2) { column ->
repeat(2) { column -> val index = position * 2 + column
val index = position * 2 + column val cell = songs.getOrNull(index)?.let {
val cell = songs.getOrNull(index)?.let { createCatalogCell(it, index, column, position == count - 1)
createCatalogCell(it, index, column, position == count - 1) } ?: View(context)
} ?: View(context) addView(
cells += cell cell,
addView( LinearLayout.LayoutParams(0, -1, 1f),
cell, )
LinearLayout.LayoutParams(0, dp(60), 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) { private fun createSingleRow(song: Song, index: Int): View = when (mode) {
@@ -135,8 +135,8 @@ object SongOkDownloadManager {
} }
val musicNo = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls") ?: song.id val musicNo = song.filename?.removeSuffix(".ts")?.removeSuffix(".ls") ?: song.id
return SongApiClient.getSongDownloadUrl(musicNo).orEmpty().also { url -> return SongApiClient.getSongDownloadUrl(musicNo).orEmpty().also { url ->
// 请求源 = 配置的歌曲源(与 JS ktv_api.js CONFIG.HOSTS 同步) // 请求源 = 本次点歌实际命中的源(JS 上报); 取不到时留空
val srcHost = "w.w345.my" val srcHost = SongApiClient.lastSongSourceHost()
if (url.isNotEmpty()) { if (url.isNotEmpty()) {
song.downloadUrl = url song.downloadUrl = url
val cdnHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "unknown" val cdnHost = runCatching { java.net.URI(url).host }.getOrNull() ?: "unknown"
@@ -97,12 +97,12 @@
android:textSize="14sp" /> android:textSize="14sp" />
</LinearLayout> </LinearLayout>
<!-- 麦克风 --> <!-- 麦克风(增益/效果由功放混音器硬件调节, 滑杆仅作指示) -->
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="4dp" android:layout_marginBottom="2dp"
android:text="麦克风音量" android:text="麦克风音量(功放/混音器硬件调节)"
android:textColor="#FFE0E0FF" android:textColor="#FFE0E0FF"
android:textSize="13sp" /> android:textSize="13sp" />
@@ -121,7 +121,8 @@
android:max="100" android:max="100"
android:progress="60" android:progress="60"
android:progressTint="#FF27CCA4" android:progressTint="#FF27CCA4"
android:thumbTint="#FF27CCA4" /> android:thumbTint="#FF27CCA4"
android:enabled="false" />
<TextView <TextView
android:id="@+id/text_mic_volume_value" android:id="@+id/text_mic_volume_value"
@@ -136,8 +137,8 @@
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="4dp" android:layout_marginBottom="2dp"
android:text="麦克风效果" android:text="麦克风效果(功放/混音器硬件调节)"
android:textColor="#FFE0E0FF" android:textColor="#FFE0E0FF"
android:textSize="13sp" /> android:textSize="13sp" />
@@ -156,7 +157,8 @@
android:max="100" android:max="100"
android:progress="50" android:progress="50"
android:progressTint="#FF27CCA4" android:progressTint="#FF27CCA4"
android:thumbTint="#FF27CCA4" /> android:thumbTint="#FF27CCA4"
android:enabled="false" />
<TextView <TextView
android:id="@+id/text_mic_effect_value" android:id="@+id/text_mic_effect_value"