Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4eb6b7b382 | ||
|
|
0549f910a6 | ||
|
|
221516f319 | ||
|
|
485235e96e | ||
|
|
2a3c4e6003 |
@@ -2,17 +2,30 @@
|
||||
|
||||
> 本文件供后续开发(含 AI 助手会话)遵循,优先级高于默认习惯。
|
||||
|
||||
## 一、代码同步策略(2026-09-11 更新)
|
||||
## 一、代码同步与发布策略(2026-09-14 更新)
|
||||
|
||||
**代码主仓库已迁移至 Gitea:https://gitea.kasugano.cn/zijunchiang/maidong-ktv**
|
||||
**代码主仓库:Gitea `https://gitea.kasugano.cn/zijunchiang/maidong-ktv`**
|
||||
|
||||
| 远端 | 地址 | 状态 |
|
||||
| 远端 | 地址 | 用途 |
|
||||
|---|---|---|
|
||||
| `origin` | `https://gitea.kasugano.cn/zijunchiang/maidong-ktv.git` | ✅ 主仓库,**同步目标** |
|
||||
| `gitee` | `https://gitee.com/zijunchiang/maidong-ktv.git` | ⛔ 已停用(推送被钩子拦截) |
|
||||
| `origin` | `https://gitea.kasugano.cn/zijunchiang/maidong-ktv.git` | ✅ 代码主仓库,**唯一的代码同步目标** |
|
||||
| `gitee` | `https://gitee.com/zijunchiang/maidong-ktv.git` | 📦 **不推代码**,只用于发布 Release;仓库内只保留 README 文档 + Releases 附件 |
|
||||
|
||||
### 四条硬性规则
|
||||
|
||||
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。
|
||||
|
||||
- ✅ 预期动作:完成改动 → 本地 `git commit` → `git push origin master`(同步到 Gitea)
|
||||
- ⛔ 禁止:推送到 Gitee(`pre-push` 钩子会拦截,避免误推送)
|
||||
- 凭据:Gitea 访问令牌 `dsh-sync`(可在 Gitea「设置 → 应用 → 访问令牌」随时吊销),
|
||||
存放于 `~/.git-credentials-gitea`(权限 600),由本仓库级 `credential.helper` 引用;
|
||||
账号密码不写入仓库配置。
|
||||
@@ -21,10 +34,12 @@
|
||||
|
||||
```bash
|
||||
cd /home/zijun/dsh/maidongktv/maidong-ktv
|
||||
git push origin master # origin = Gitea(主仓库)
|
||||
git push --no-verify gitee master # 仅在确需回推 Gitee 时使用
|
||||
git push origin master # origin = Gitea(唯一的代码同步目标)
|
||||
```
|
||||
|
||||
> Gitee 不参与代码同步;确需回推文档时用 `git push --no-verify gitee master`,
|
||||
> 但按规则 2 正常情况下不应执行。
|
||||
|
||||
### 运行时端点(已全部迁移到 Gitea)
|
||||
|
||||
App 内部两处端点已指向 Gitea(随本版 APK 生效):
|
||||
@@ -88,7 +103,7 @@ $BT/apksigner verify 目标名.apk
|
||||
|
||||
### 版本号
|
||||
|
||||
- `app/build.gradle`:`versionName "1.3.0"` / `versionCode 6`
|
||||
- `app/build.gradle`:`versionName "1.3.3"` / `versionCode 9`
|
||||
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
|
||||
|
||||
## 三、关键实现约束(避免踩坑)
|
||||
@@ -98,10 +113,44 @@ $BT/apksigner verify 目标名.apk
|
||||
- **曲库**:只走官方 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`/`loglevel`/`traceid`/`user`;`date` 必须为
|
||||
`yyyy-MM-dd HH:mm:ss.SSS`,否则按时间检索异常。客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),
|
||||
调用点 `MiliLog.info/notice/warn/error` 保持不变;设备 IP/标识通过 `MiliLog.updateDeviceContext()` 注入,
|
||||
网络变化时自动刷新
|
||||
`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)
|
||||
|
||||
+2
-2
@@ -11,8 +11,8 @@ android {
|
||||
applicationId "com.local.ktv"
|
||||
minSdk 21
|
||||
targetSdk 25
|
||||
versionCode 6
|
||||
versionName "1.3.0"
|
||||
versionCode 9
|
||||
versionName "1.3.3"
|
||||
ndk {
|
||||
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
|
||||
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
|
||||
|
||||
@@ -72,9 +72,9 @@ async function refresh() {
|
||||
$("#playButton span:last-child").textContent = data.playing ? "暂停" : "继续";
|
||||
$("#playButton").dataset.action = data.playing ? "pause" : "resume";
|
||||
$("#playButton .control-icon").textContent = data.playing ? "Ⅱ" : "▶";
|
||||
$("#musicVolume").value = data.musicVolume ?? 70;
|
||||
$("#musicVolume").value = data.musicVolume ?? 85;
|
||||
$("#micVolume").value = data.micVolume ?? 70;
|
||||
$("#musicValue").textContent = data.musicVolume ?? 70;
|
||||
$("#musicValue").textContent = data.musicVolume ?? 85;
|
||||
// 麦克风音量由功放/混音器硬件调节(滑杆已禁用), 仅保留显示值。
|
||||
const micHw = $("#micValue");
|
||||
if (micHw) micHw.textContent = data.micVolume ?? 70;
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<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">♪</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>
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -42,9 +42,10 @@ class KtvJsBridge(context: Context) {
|
||||
"https://gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js"
|
||||
private const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||
private const val JS_REQUEST_TIMEOUT_MS = 180_000
|
||||
// 当前歌曲源 w.w345.my 使用的设备标识(mac + sn)。与用户抓包示例一致,
|
||||
// w.w345.my 与 e.ac19.cn 同后台,按 device 识别; 随机/变化的 device 会被风控。
|
||||
private const val DEVICE_MAC = "ae60e9547958"
|
||||
// 歌曲源(w.w345.my 与 e.ac19.cn 同后台)按 device 识别; 随机/变化的 device 会被风控。
|
||||
// mac 取本机「当前在用网卡」的硬件地址(读不到时回退历史抓包值, 见 DeviceMac);
|
||||
// sn 沿用抓包注册值, 保证 mac 变化后仍能对上原有账号体系。
|
||||
private val DEVICE_MAC: String get() = DeviceMac.effective()
|
||||
private const val DEVICE_SN = "2d7bb4a7d7f7"
|
||||
}
|
||||
|
||||
@@ -144,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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -162,7 +162,8 @@ class MainActivity : AppCompatActivity() {
|
||||
startRemoteServer()
|
||||
updateMobileQrOverlay()
|
||||
updateFullScreenQrOverlay()
|
||||
// 网络/IP 变化后同步日志上报的 serverip。
|
||||
// 网络切换后重新解析当前网卡 MAC,并同步日志上报的 IP/设备标识。
|
||||
DeviceMac.refresh()
|
||||
refreshLogDeviceContext()
|
||||
}
|
||||
main.postDelayed(this, 5000)
|
||||
@@ -245,6 +246,9 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun run() {
|
||||
updateFullScreenProgress()
|
||||
updateLyricView()
|
||||
// 播放界面左上角的「当前播放 / 下一首」实时刷新: 无论队列是本地点歌、
|
||||
// 手机扫码点歌还是自动切歌而变化, 500ms 内必然同步到界面。
|
||||
refreshNowPlayingLabels()
|
||||
main.postDelayed(this, 500)
|
||||
}
|
||||
}
|
||||
@@ -372,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 = "标准"
|
||||
@@ -2419,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
|
||||
@@ -2470,7 +2477,7 @@ class MainActivity : AppCompatActivity() {
|
||||
indicator.alpha = 1f
|
||||
indicator.visibility = View.VISIBLE
|
||||
renderMuteButton()
|
||||
applySystemMusicVolume()
|
||||
// 只调 App 内部输出音量, 不改系统音量(见 applyPlaybackMode)。
|
||||
applyPlaybackMode()
|
||||
saveState()
|
||||
main.removeCallbacks(hideFullScreenVolumeIndicatorRunnable)
|
||||
@@ -2627,7 +2634,6 @@ class MainActivity : AppCompatActivity() {
|
||||
} else {
|
||||
musicVolume = muteBeforeVol
|
||||
}
|
||||
applySystemMusicVolume()
|
||||
applyPlaybackMode()
|
||||
seekMusicVol.setProgress(musicVolume)
|
||||
tvMusicVol.setText(musicVolume.toString())
|
||||
@@ -2663,7 +2669,6 @@ class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onStartTrackingTouch(s: SeekBar?) {}
|
||||
override fun onStopTrackingTouch(s: SeekBar?) {
|
||||
applySystemMusicVolume()
|
||||
applyPlaybackMode()
|
||||
saveState()
|
||||
}
|
||||
@@ -2673,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")
|
||||
@@ -3140,6 +3144,8 @@ class MainActivity : AppCompatActivity() {
|
||||
bottomMargin = dp(76)
|
||||
}
|
||||
root.addView(box, params)
|
||||
// 二维码浮层是展示型表面: 被焦点填充盖住会直接遮掉二维码。
|
||||
TvFocusStyler.suppressFill(box)
|
||||
mobileQrOverlay = box
|
||||
}
|
||||
|
||||
@@ -3178,6 +3184,8 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
container.addView(box, params)
|
||||
box.bringToFront()
|
||||
// 同手机点歌二维码: 展示型表面不做焦点填充。
|
||||
TvFocusStyler.suppressFill(box)
|
||||
fullScreenQrOverlay = box
|
||||
}
|
||||
|
||||
@@ -3471,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"))
|
||||
@@ -3507,50 +3515,57 @@ class MainActivity : AppCompatActivity() {
|
||||
}, "ktv-heartbeat").start()
|
||||
}
|
||||
|
||||
/** 远程日志: 把设备 IP 与设备标识同步给日志客户端(GlogCenter 的 serverip/user/traceid)。 */
|
||||
/** 当前设备 MAC(12 位小写十六进制): 本机当前在用网卡, 读不到时回退历史抓包值。 */
|
||||
private fun deviceMac(): String = DeviceMac.effective()
|
||||
|
||||
/** 远程日志: 把设备网卡 IP 与设备标识同步给日志客户端(GlogCenter 的 clientip/user/traceid)。 */
|
||||
private fun refreshLogDeviceContext() {
|
||||
MiliLog.updateDeviceContext(ip = localIp(), deviceId = LEGACY_DEVICE_ID)
|
||||
MiliLog.updateDeviceContext(ip = localIp(), deviceId = deviceMac())
|
||||
}
|
||||
|
||||
/** 远程日志: App 启动时上报设备信息与网络信息(异步, 尽力而为)。 */
|
||||
/** 远程日志: App 启动时上报设备信息(异步, 尽力而为)。 */
|
||||
private fun reportDeviceBootInfo() {
|
||||
Thread({
|
||||
try {
|
||||
val sb = StringBuilder()
|
||||
sb.append("BOOT pkg=").append(packageName)
|
||||
sb.append("BOOT")
|
||||
sb.append(" pkg=").append(packageName)
|
||||
sb.append(" ver=").append(BuildConfig.VERSION_NAME)
|
||||
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(" 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,
|
||||
@@ -4314,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))
|
||||
}
|
||||
@@ -5386,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)
|
||||
@@ -6621,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() })
|
||||
@@ -6660,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))
|
||||
@@ -6709,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
|
||||
@@ -6755,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)
|
||||
@@ -7084,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()
|
||||
} ?: "未播放"
|
||||
@@ -7121,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"
|
||||
@@ -8553,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
|
||||
@@ -10229,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
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ import java.util.concurrent.atomic.AtomicBoolean
|
||||
* date 日期时间,格式 `yyyy-MM-dd HH:mm:ss.SSS`(务必与检索格式一致)
|
||||
* text 日志内容
|
||||
* servername 主机名(设备型号)
|
||||
* serverip 主机 IP(设备局域网 IP)
|
||||
* serverip 主机 IP —— 公网出口 IP,由 [PUBLIC_IP_URL] 查询,结果缓存 [PUBLIC_IP_TTL_MS]
|
||||
* clientip 客户端 IP —— 设备当前网卡获取到的局域网 IP(由调用方注入)
|
||||
* loglevel 日志级别(INFO / WARN / ERROR)
|
||||
* traceid 追踪码(此处用设备标识)
|
||||
* user 用户(此处用设备标识)
|
||||
@@ -35,6 +36,16 @@ object MiliLog {
|
||||
private const val TAG = "MiliLog"
|
||||
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
|
||||
@@ -46,11 +57,18 @@ object MiliLog {
|
||||
|
||||
private val started = AtomicBoolean(false)
|
||||
private val running = AtomicBoolean(false)
|
||||
private val publicIpFetching = AtomicBoolean(false)
|
||||
private val queue = LinkedBlockingQueue<String>(MAX_QUEUE)
|
||||
private var senderThread: Thread? = null
|
||||
|
||||
@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 = ""
|
||||
|
||||
@@ -60,11 +78,21 @@ object MiliLog {
|
||||
serverName = sanitize(Build.MODEL).ifEmpty { "android" }
|
||||
running.set(true)
|
||||
senderThread = Thread({ sendLoop() }, "mili-glog").also { it.isDaemon = true; it.start() }
|
||||
ensureServerIp()
|
||||
}
|
||||
|
||||
/** 更新上报用的设备网络/身份字段(IP 变化或重新登录时调用)。 */
|
||||
/**
|
||||
* 更新上报用的客户端网络/身份字段。
|
||||
*
|
||||
* @param ip 设备当前网卡获取到的局域网 IP(clientip),变化时会顺带复核公网 IP
|
||||
* @param deviceId 设备标识(同时写入 user / traceid)
|
||||
*/
|
||||
fun updateDeviceContext(ip: String? = null, deviceId: String? = null) {
|
||||
if (!ip.isNullOrBlank()) serverIp = ip
|
||||
if (!ip.isNullOrBlank() && ip != clientIp) {
|
||||
clientIp = ip
|
||||
// 局域网 IP 变化通常意味着换了网络,公网出口 IP 需要立即复核。
|
||||
ensureServerIp(force = true)
|
||||
}
|
||||
if (!deviceId.isNullOrBlank()) {
|
||||
user = deviceId
|
||||
traceId = deviceId
|
||||
@@ -78,6 +106,8 @@ object MiliLog {
|
||||
|
||||
private fun enqueue(level: String, msg: String) {
|
||||
if (!started.get()) return
|
||||
// 公网 IP 过期时顺带后台刷新(内部有 5 分钟节流,不会频繁调用外部接口)。
|
||||
ensureServerIp()
|
||||
val body = buildBody(level, msg) ?: return
|
||||
// 队列满时丢最旧,保证新日志能进队。
|
||||
if (!queue.offer(body)) {
|
||||
@@ -101,6 +131,7 @@ object MiliLog {
|
||||
put("text", truncated)
|
||||
put("servername", serverName)
|
||||
put("serverip", serverIp)
|
||||
put("clientip", clientIp)
|
||||
put("loglevel", level)
|
||||
put("traceid", traceId)
|
||||
put("user", user)
|
||||
@@ -116,6 +147,86 @@ object MiliLog {
|
||||
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}")
|
||||
""
|
||||
}
|
||||
|
||||
// ─── 发送线程 ────────────────────────────────────
|
||||
|
||||
private fun sendLoop() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user