Files
maidong-ktv/app/src/main/java/com/local/ktv/SongApiClient.kt
T
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

116 lines
4.4 KiB
Kotlin

package com.local.ktv
import android.content.Context
import android.os.Looper
import android.util.Log
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* 歌曲 API 客户端 —— JS 热更新桥接
* =================================
* - 通过 KtvJsBridge 调用 Gitea 上的 JS 脚本
* - API 变更时只需更新 Gitea JS 文件,App 无需发版
* - 首次播放时实时获取 URL,不存在过期问题
*/
object SongApiClient {
private const val TAG = "SongApiClient"
private var bridge: KtvJsBridge? = null
@Volatile private var ready = false
@JvmStatic
fun init(context: Context) {
if (bridge != null) return
bridge = KtvJsBridge(context)
bridge?.init { ok ->
ready = ok
Log.i(TAG, "JS Bridge init: $ok")
}
}
/** 兼容旧接口 */
@JvmStatic
fun init(sn: String, mac: String) {
// JS 脚本自己生成 MAC/SN
}
@JvmStatic
fun getAuthToken(): String = ""
@JvmStatic
fun getSongDownloadUrl(musicno: String?): String? =
musicno?.takeIf(String::isNotBlank)?.let { getSongDownloadUrl(it, "720", false) }
@JvmStatic
fun getSongDownloadUrl(musicno: String, resolution: String, h265: Boolean): String? {
// 主线程同步等待 WebView 回调会死锁;当前播放/下载流程会在 IO 线程调用这里。
if (Looper.myLooper() == Looper.getMainLooper()) {
Log.w(TAG, "Synchronous JS request on main thread; using DB fallback")
return fetchFromDb(musicno)
}
val bridge = bridge ?: return fetchFromDb(musicno)
// 等待 JS 桥"当前"真正就绪。首次冷启动时隐藏 WebView 的初始化在低性能设备
// (极米投影等)上可能超过 3 秒;桥热更新重载期间 isReady() 也会短暂为 false。
// 这里放宽到 15 秒,避免第一首歌因撞上就绪窗口而回退到不可靠的 DB cloud_url。
val deadline = System.currentTimeMillis() + 15_000L
while (!bridge.isReady() && System.currentTimeMillis() < deadline && !bridge.isDestroyed()) {
try { Thread.sleep(100) } catch (_: InterruptedException) {
Thread.currentThread().interrupt()
break
}
}
if (!bridge.isReady()) {
Log.w(TAG, "Bridge not ready after wait, DB fallback")
return fetchFromDb(musicno)
}
val result = AtomicReference<String?>()
val latch = CountDownLatch(1)
bridge.getSongUrl(musicno, resolution, h265) { url -> result.set(url); latch.countDown() }
// JS 需要覆盖主/备节点和最多 3 次设备重试;略长于桥内 180 秒总超时。
try {
latch.await(185, TimeUnit.SECONDS)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
val url = result.get() ?: fetchFromDb(musicno)
if (url != null) {
// 成功取到歌曲 URL → 歌曲源窗口已恢复/未到期, 复位到期提示状态。
Eac19WindowGuard.noteUrlOk()
}
return url
}
/** DB cloud_url fallback — 必须通过 demo 检测才能使用 */
private fun fetchFromDb(musicno: String): String? {
try {
val db = MuseDatabase()
if (db.open()) {
val url = db.getCloudUrl(musicno)
db.close()
// DB 中的 cloud_url 可能也是 demo 地址, 做 URL 基本校验
if (url != null && !isLikelyDemoUrl(url)) return url
}
} catch (_: Exception) {}
return null
}
/** 快速判断是否为 demo URL (不含 JS 的完整逻辑, 仅做硬特征兜底) */
private fun isLikelyDemoUrl(url: String): Boolean {
val host = runCatching { java.net.URI(url).host }.getOrNull() ?: return true
// 纯 IP 地址 → 高概率 demo
if (host.matches(Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"))) return true
// Cloudflare R2 demo bucket (pub-*.r2.dev)
if (host.matches(Regex("^pub-[a-z0-9]+\\.r2\\.dev$"))) return true
return false
}
@JvmStatic
fun clearTokenCache() {}
/** 最近一次点歌实际命中的歌曲源 host(由 JS 上报, 用于远程日志)。 */
@JvmStatic
fun lastSongSourceHost(): String = bridge?.lastSongSourceHost.orEmpty()
}