项目简介
MOMO 是一款基于 Kotlin + Jetpack Compose 开发的 Android 视频播放应用,使用 ExoPlayer (androidx media3 1.6.1) 作为播放内核,通过苹果CMS采集API获取视频资源。本篇记录了从 v1.5.0 到 v1.5.6 的完整开发历程,涵盖播放器优化、用户系统、会员接入、在线更新分发、画中画、弹幕播放器等核心功能。
技术栈
- 语言 — Kotlin
- UI框架 — Jetpack Compose (Material 3)
- 播放器 — androidx.media3 (ExoPlayer) 1.6.1
- 本地存储 — DataStore Preferences
- 网络 — Retrofit + OkHttp
- 序列化 — kotlinx.serialization
- 图片加载 — Coil 3
- 服务器 — Oracle Cloud (Ubuntu aarch64, Singapore, IP: 161.118.252.183)
- Web服务 — Nginx + 宝塔面板
- 视频源 — 苹果CMS采集API
播放器进度条拖拽优化
原版播放器中,用户在进度条上拖拽时,手指移动极小距离就会导致播放进度大范围跳动,体验很差。
问题分析
原始实现是直接将触摸位置映射为播放进度,即 progress = touchX / trackWidth。屏幕上进度条较窄,手指微小移动就对应了大比例变化。
解决方案
改为 delta 相对位移 模式:不再直接跳转到触摸位置,而是根据手指滑动的增量(delta)来调整进度,并引入灵敏度系数控制。
// 灵敏度系数,越小越精确
var seekSensitivity = 0.5f
// 计算增量位移
val delta = (touchX - lastTouchX) / trackWidth
val progressDelta = delta * seekSensitivity
targetProgress = (currentProgress + progressDelta).coerceIn(0f, 1f)
同时将触摸区域从 20dp 增加到 36dp,让用户更容易精准控制。
首页分类内容修复
首页各分类(动作片、国产剧、综艺、动漫、短剧)显示的内容完全相同。
根因
苹果CMS API 的 tid 参数是”包含子分类查询”,传入父分类ID时返回所有子分类内容,导致多个板块看起来一样。而 t 参数是严格匹配,只返回指定分类。
修复
// ApiService.kt -- 修改前
@GET("api.php/provide/vod/")
suspend fun getVideoList(
@Query("tid") typeId: String,
@Query("page") page: Int
): VodListResponse
// 修改后 -- tid 改为 t,严格匹配
@GET("api.php/provide/vod/")
suspend fun getVideoList(
@Query("t") typeId: String,
@Query("page") page: Int
): VodListResponse
同时将分类ID从父分类改为子分类:
// HomeViewModel.kt / SplashViewModel.kt
val categories = mapOf(
"动作片" to "23",
"国产剧" to "30",
"大陆综艺" to "39",
"国产动漫" to "43",
"短剧" to "60"
)
用户名密码登录注册系统
去掉原腾讯云开发的邮箱/手机号/验证码体系,改为纯本地用户名+密码注册/登录,登录即永久最高级会员。
数据模型
// AuthModels.kt
data class User(
val uid: String = "",
val email: String = "",
val phone: String = "",
val displayName: String = "",
val photoUrl: String? = null,
val isEmailVerified: Boolean = false,
val isVip: Boolean = true,
val vipExpireTime: Long = Long.MAX_VALUE // 永久会员
)
本地用户存储
注册用户列表通过 JSON 序列化存储在 DataStore 中,uid 以 local_ 前缀标识本地用户:
// UserPreferences.kt
@Serializable
private data class RegisteredUser(val username: String, val password: String)
suspend fun registerLocalUser(username: String, password: String): Result<User> {
if (username.length < 2) return Result.failure(Exception("用户名至少2个字符"))
if (password.length < 4) return Result.failure(Exception("密码至少4个字符"))
val users = getRegisteredUsers()
if (users.any { it.username == username }) {
return Result.failure(Exception("用户名已存在"))
}
val newUsers = users + RegisteredUser(username, password)
saveRegisteredUsers(newUsers)
val user = User(
uid = "local_$username",
displayName = username,
isVip = true,
vipExpireTime = Long.MAX_VALUE
)
saveUserLogin(user)
saveLocalCredentials(username, password)
return Result.success(user)
}
suspend fun loginLocalUser(username: String, password: String): Result<User> {
val users = getRegisteredUsers()
val match = users.find { it.username == username }
if (match == null) return Result.failure(Exception("用户不存在"))
if (match.password != password) return Result.failure(Exception("密码错误"))
val user = User(
uid = "local_$username",
displayName = username,
isVip = true,
vipExpireTime = Long.MAX_VALUE
)
saveUserLogin(user)
saveLocalCredentials(username, password)
return Result.success(user)
}
AuthViewModel
// AuthViewModel.kt
fun login() {
val state = _uiState.value
if (!validateInput(state)) return
viewModelScope.launch {
_authResultState.value = UiState.Loading
val result = prefs.loginLocalUser(state.username, state.password)
result.onSuccess { user ->
AuthRepository.restoreSession(user, "local_token", "local_refresh")
_authResultState.value = UiState.Success(user)
}.onFailure {
_authResultState.value = UiState.Error(it.message ?: "登录失败")
}
}
}
fun register() {
val state = _uiState.value
if (!validateInput(state)) return
viewModelScope.launch {
_authResultState.value = UiState.Loading
val result = prefs.registerLocalUser(state.username, state.password)
result.onSuccess { user ->
AuthRepository.restoreSession(user, "local_token", "local_refresh")
_authResultState.value = UiState.Success(user)
}.onFailure {
_authResultState.value = UiState.Error(it.message ?: "注册失败")
}
}
}
会话恢复
App 启动时对 local_ 开头的 uid 跳过云端验证,直接恢复本地登录状态。
UI
AuthScreen 采用暗色主题 + 樱花粉强调色,用户名/密码输入框,登录/注册切换,金色 VIP 提示条。
// AuthScreen.kt -- 核心UI结构
Surface(
color = SakuraPrimary.copy(alpha = 0.12f)
) {
Text("登录即享永久最高级会员", color = SakuraPrimary)
}
OutlinedTextField(
value = uiState.username,
onValueChange = { viewModel.onUsernameChange(it) },
label = { Text("用户名") },
leadingIcon = { Icon(Icons.Outlined.Person, "Username") }
)
OutlinedTextField(
value = uiState.password,
onValueChange = { viewModel.onPasswordChange(it) },
label = { Text("密码") },
visualTransformation = PasswordVisualTransformation()
)
// 登录/注册切换
Text(
text = if (isLoginMode) "没有账号?去注册" else "已有账号?去登录",
color = SakuraPrimary,
modifier = Modifier.clickable { isLoginMode = !isLoginMode }
)
会员系统接入苹果CMS(JWT 认证)
v1.5.4 将用户系统从”本地 DataStore 假登录”升级为对接苹果CMS 会员体系:APP 与网站共用同一张 mac_user 表、同一套认证,同时保留用户名 + 密码登录方式(网站端仅开放 L 站 OAuth,APP 端走账号密码)。
认证流程
APP 登录
| POST /api.php/auth/jwt (user_name + user_pwd)
v
JWT Token (HS256, 有效期 7 天)
| 后续请求 Authorization: Bearer <token>
v
GET /api.php/auth/me 用户信息
POST /api.php/user/register 注册
POST /api.php/user/update_info 修改昵称
服务器端改动
苹果CMS 自带 JWT 基础设施(JwtService + /api.php/auth/jwt 端点),只需两步开启:
// application/extra/maccms.php -- 启用 JWT
'api_jwt_enabled' => '1', // 原为 0
'api_jwt_ttl' => '604800', // 7 天,原为 7200 秒
同时修改 application/common/controller/All.php 的 label_user():无 Cookie 但携带合法 JWT Bearer token 时也调用 checkLogin() 填充用户,使所有 API 接口(收藏/历史/权限判断)都能识别 APP 登录用户。
APP 端改动
// 新增 MacCmsAuthApi.kt
interface MacCmsAuthApi {
@POST("api.php/auth/jwt")
@FormUrlEncoded
suspend fun jwtLogin(@Field("user_name") userName: String,
@Field("user_pwd") userPwd: String): MacCmsJwtResponse
@GET("api.php/auth/me")
suspend fun getMe(@Header("Authorization") authorization: String): MacCmsMeResponse
@POST("api.php/user/register")
@FormUrlEncoded
suspend fun register(@Field("user_name") userName: String,
@Field("user_pwd") userPwd: String,
@Field("user_pwd2") userPwd2: String): MacCmsSimpleResponse
}
// NetworkModule.kt -- 所有请求自动附加 JWT
private val jwtInterceptor = Interceptor { chain ->
val token = AuthRepository.getAccessToken()
val request = if (token != null && !token.isBlank()) {
chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else chain.request()
chain.proceed(request)
}
AuthRepository 重写:signInWithPassword() 调 jwt 登录、register() 调注册接口后自动登录、reloadUser() 调 /auth/me;删除全部腾讯云开发(CloudBase)认证代码。VIP 判定改为 group_id >= 3 且未过期(对齐网站会员组)。
验证结果
- 注册:
{"code":1,"msg":"注册成功"} - 登录:返回
access_token(7 天有效) - 错误密码:
code=1003(APP 端映射为”用户名或密码错误”) - 权限接口:带 token
is_login=1,无 tokenis_login=0
个人中心 VIP 徽章 + 检查更新
ProfileScreen 展示金色「VIP 永久」徽章,并在菜单中增加「检查更新」入口,点击后查询服务器版本并弹出更新弹窗(含下载进度条)。
// ProfileScreen.kt -- VIP徽章
Surface(
shape = RoundedCornerShape(20.dp),
color = Brush.horizontalGradient(
colors = listOf(Color(0xFFFFD700), Color(0xFFFFA500))
)
) {
Text("VIP 永久", color = Color.White, fontWeight = FontWeight.Bold)
}
自建在线更新分发系统
摒弃第三方应用市场,利用自己的 Oracle 云服务器搭建完整的 APK 在线更新与分发链路。
架构
App 检查更新
| HTTP GET
v
http://161.118.252.183/update.json
| 返回版本信息
v
{
"versionName": "154",
"versionCode": 15400,
"apkUrl": "http://161.118.252.183/release/latest.apk",
"apkSize": 27574888,
"md5": "8896faf5eace0de993d4560449a559f5",
"forceUpdate": false,
"updateLog": "接入苹果CMS会员系统,支持用户名密码登录",
"publishTime": "2026-08-09T11:23:41Z"
}
| 下载APK
v
http://161.118.252.183/release/latest.apk
| 安装
v
FileProvider + ACTION_VIEW
Nginx 配置
# /etc/nginx/sites-available/momo.7998888.xyz
server {
listen 80;
listen [::]:80;
server_name momo.7998888.xyz 161.118.252.183;
client_max_body_size 100m;
root /www/wwwroot/momo-release;
index update.json;
# 版本检查接口
location = /update.json {
default_type application/json;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Access-Control-Allow-Origin "*";
}
# APK 下载
location /release/ {
alias /www/wwwroot/momo-release/;
autoindex on;
add_header Access-Control-Allow-Origin "*";
add_header Cache-Control "public, max-age=3600";
location ~ .apk$ {
default_type application/vnd.android.package-archive;
add_header Cache-Control "public, max-age=3600";
}
}
}
App 端 UpdateChecker
// UpdateChecker.kt
class UpdateChecker {
companion object {
private const val UPDATE_URL = "http://161.118.252.183/update.json"
suspend fun checkForUpdate(): AppVersionInfo? = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url(UPDATE_URL)
.header("Cache-Control", "no-cache")
.build()
val response = client.newCall(request).execute()
val json = JSONObject(response.body?.string() ?: return@withContext null)
val serverVersionCode = json.optInt("versionCode", 0)
val currentVersionCode = BuildConfig.VERSION_CODE
if (serverVersionCode <= currentVersionCode) return@withContext null
AppVersionInfo(
versionName = json.optString("versionName"),
apkDownloadUrl = json.optString("apkUrl"),
forceUpdate = json.optBoolean("forceUpdate", false),
updateLog = json.optString("updateLog")
)
}
suspend fun downloadApk(
context: Context,
downloadUrl: String,
onProgress: (Float) -> Unit
): File? = withContext(Dispatchers.IO) {
// OkHttp 流式下载,8192 字节缓冲区
// 下载到 cacheDir/update_xxxx.apk
}
fun installApk(context: Context, apkFile: File) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
}
一键发布脚本
#!/bin/bash
# publish.sh - 编译并发布 MOMO APK
# 用法: ./publish.sh <versionName> <updateLog>
set -e
PROJECT_DIR="/opt/ysxq"
RELEASE_DIR="/www/wwwroot/momo-release"
VERSION_NAME="${1:-}"
UPDATE_LOG="${2:-功能更新}"
# 计算 versionCode (如 1.5.0 -> 150)
VC_PARTS=(${VERSION_NAME//./ })
VERSION_CODE=$(( ${VC_PARTS[0]} * 100 + ${VC_PARTS[1]:-0} * 10 + ${VC_PARTS[2]:-0} ))
# 更新 build.gradle.kts
sed -i "s/versionCode = .*/versionCode = ${VERSION_CODE}/" app/build.gradle.kts
sed -i "s/versionName = "[^"]*"/versionName = "${VERSION_NAME}"/" app/build.gradle.kts
# 编译
./gradlew assembleDebug --console=plain
# 复制 APK
cp app/build/outputs/apk/debug/app-debug.apk "${RELEASE_DIR}/momo-${VERSION_NAME}.apk"
cp app/build/outputs/apk/debug/app-debug.apk "${RELEASE_DIR}/latest.apk"
# 生成 update.json
cat > "${RELEASE_DIR}/update.json" << EOF
{
"versionName": "${VERSION_NAME}",
"versionCode": ${VERSION_CODE},
"apkUrl": "http://161.118.252.183/release/latest.apk",
"apkSize": $(stat -c%s "${RELEASE_DIR}/latest.apk"),
"md5": "$(md5sum "${RELEASE_DIR}/latest.apk" | awk '{print $1}')",
"forceUpdate": false,
"updateLog": "${UPDATE_LOG}",
"publishTime": "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
}
EOF
echo "=== 发布完成 ==="
echo "下载: http://161.118.252.183:8899/release/latest.apk"
发布只需一条命令:
ssh oracle 'cd /opt/ysxq && ./publish.sh 154 "接入苹果CMS会员系统,支持用户名密码登录"'
DNS 问题排查
最初使用域名 momo.7998888.xyz 作为更新地址,但 DNS 未配置解析,导致 App 检查更新时请求超时、无响应。
解决方案:直接使用服务器 IP 161.118.252.183 作为更新地址,nginx server_name 同时匹配域名和 IP,UpdateChecker 和 update.json 中的 URL 全部改为 IP 直连。
意见反馈邮箱修复
FeedbackScreen 和 AboutScreen 中的反馈邮箱从 2695850556@qq.com 修改为 wuliang@duck.com。
// FeedbackScreen.kt / AboutScreen.kt
private const val FEEDBACK_EMAIL = "wuliang@duck.com"
画中画(PiP)+ 后台播放
v1.5.5 起支持系统级画中画:播放视频时按 Home 键,播放器自动缩小为悬浮小窗继续播放,可边看边操作其他 App。
实现思路
由于播放器 UI 是 Compose 实现的,PiP 需要桥接 Compose 与 Activity 生命周期,核心是一个单例 PipHelper:
// PipHelper.kt - Compose ↔ Activity 桥接
object PipHelper {
@Volatile var hasActiveVideo = false // 是否有视频在播
@Volatile var isPlaying = false // 是否播放中
@Volatile var videoWidth = 0 // 视频宽
@Volatile var videoHeight = 0 // 视频高
@Volatile var isInPipMode = false // 是否处于 PiP 模式
// MainActivity.onUserLeaveHint() 调用:按 Home 时尝试进入 PiP
fun tryEnterPip(activity: Activity) {
if (!hasActiveVideo || !isPlaying) return
val aspect = Rational(
Math.round((videoWidth.toFloat() / videoHeight) * 1000), 1000)
activity.enterPictureInPictureMode(
PictureInPictureParams.Builder()
.setAspectRatio(aspect).build())
}
}
关键点:宽高比用 Rational(Math.round(ratio*1000), 1000) 构造,避免浮点转整数精度丢失导致 PiP 窗口比例异常。
生命周期联动
onUserLeaveHint()→PipHelper.tryEnterPip(this)onPictureInPictureModeChanged()→ 更新isInPipMode,隐藏/恢复系统栏- DetailScreen 的
ProcessLifecycleObserver ON_STOP→ 加!PipHelper.isInPipMode条件,PiP 模式不暂停播放 onIsPlayingChanged / onVideoSizeChanged→ 实时同步播放状态与视频尺寸
自动播放下一集 + 倍速记忆
追剧体验优化:一集播完自动无缝进入下一集;手动调整的播放倍速跨会话记忆,下次打开自动恢复。
// 播完自动下一集
} else if (playbackState == Player.STATE_ENDED) {
val s = viewModel.state.value
val currentEp = s.currentEpisodeIndex
val totalEps = s.sources.getOrNull(s.currentSourceIndex)?.episodes?.size ?: 0
if (currentEp >= 0 && currentEp + 1 < totalEps) {
viewModel.selectEpisode(currentEp + 1)
userRequestedPlay = true
}
}
// 倍速持久化(DataStore floatPreferencesKey)
val KEY_PLAYBACK_SPEED = floatPreferencesKey("playback_speed")
prefs.playbackSpeed.collect { savedSpeed ->
if (savedSpeed != 1.0f && savedSpeed > 0f) {
exoPlayer.setPlaybackParameters(PlaybackParameters(savedSpeed))
}
}
下拉刷新 + 缓冲进度条 + 字幕轨道
- 首页/分类下拉刷新:Material3
PullToRefreshBox包裹首页与分类网格,下拉触发loadHome(forceRefresh=true)/viewModel.refresh() - 缓冲进度条:读
exoPlayer.bufferedPercentage,在播放进度条下方叠加浅灰缓冲条,播放进度实时反馈 - 字幕轨道选择:检测
TRACK_TYPE_TEXT轨道数量,底部控制栏新增字幕按钮,弹窗列出全部字幕轨道,通过TrackSelectionParameters.setTrackTypeDisabled开关
ExoPlayer 深度优化(media3 1.6.1)
media3 从 1.5.1 升级到 1.6.1(起播需 60% 更少缓冲数据 + 解码器预热,切集零延迟),并按最佳实践重配 LoadControl:
val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(15_000, 50_000, 1_500, 2_000) // 起播只需 1.5s 缓冲
.setPrioritizeTimeOverSizeThresholds(true) // 按时长缓冲,省流量
.setBackBuffer(10_000, false) // 回拖进度不重新下载
.build()
val renderersFactory = DefaultRenderersFactory(context)
.setEnableDecoderFallback(true) // 解码器降级兜底
.setExtensionRendererMode(EXTENSION_RENDERER_MODE_PREFER) // 扩展解码器优先
ExoPlayer.Builder(context)
.setRenderersFactory(renderersFactory)
.setLoadControl(loadControl)
.setSeekParameters(SeekParameters.EXACT) // 精确 seek,不跳关键帧
.setHandleAudioBecomingNoisy(true) // 拔耳机自动暂停
.build()
B站风格弹幕播放器
v1.5.6 最大的亮点:纯 Compose 自绘弹幕引擎,零外部依赖,从零实现了一套接近 B站体验的弹幕系统——滚动弹幕、顶部/底部固定弹幕、轨道避让、描边渲染,以及弹幕开关、发送、设置面板。
为什么不用现成弹幕库
业界主流是 DanmakuFlameMaster(B站官方开源)或 BiliPai 用的 ByteDance DanmakuRenderEngine。但本项目坚持”零新依赖”原则,且 Compose 环境用 AndroidView 套原生 View 反而割裂,因此用 Canvas + nativeCanvas.drawText 自绘,完全掌控渲染细节。
架构:ui/danmaku/ 包(5 个文件)
ui/danmaku/
|-- DanmakuModels.kt # 数据模型:DanmakuItem / DanmakuConfig / 轨道状态
|-- DanmakuEngine.kt # DanmakuOverlay Composable:Canvas 渲染 + 60fps 动画循环 + 轨道管理
|-- DanmakuParser.kt # 解析 B站标准 XML 弹幕格式 + 示例弹幕生成
|-- DanmakuSettingsPanel.kt # 底部弹出设置面板:透明度/字号/速度/显示区域/类型屏蔽
`-- DanmakuInputBar.kt # 弹幕发送栏:输入框 + 8色选择 + 类型选择 + 粉色发送按钮
渲染核心
// 先画黑色描边,再画彩色填充(B站弹幕标准效果)
drawIntoCanvas { canvas ->
val native = canvas.nativeCanvas
native.drawText(text, x, y, strokePaint) // Stroke 黑色描边
native.drawText(text, x, y, fillPaint) // Fill 彩色填充
}
轨道管理(关键难点)
用 mutableStateMapOf 记录每条轨道的状态:
- 滚动轨道
scrollTracks:记录每条轨道最后一条弹幕尾部的 x 坐标,新弹幕需等前一条尾部移出屏幕(留 20dp 间距)才能进入同轨道 - 顶部/底部固定轨道
topTracks / bottomTracks:记录轨道最后占用时间,固定弹幕显示 4 秒后释放轨道 - 轨道数 = 显示区域高度 ÷ 行高,支持 1/4屏、半屏、3/4屏、全屏四种显示区域
兼容 B站 XML 弹幕格式
<d p="12.345,1,16777215,123456">弹幕文本</d>
<!-- p 属性: time,type,color(十进制RGB),uid
type: 1=滚动 4=底部 5=顶部 -->
播放器 UI 集成
- 顶部栏:新增粉色”发弹幕”按钮 → 弹出 B站风格输入栏(输入框 + 颜色选择 + 滚动/顶部/底部类型切换 + 粉色发送按钮)
- 底部控制栏:新增弹幕开关按钮(启用时 #FB7299 粉色高亮)+ 弹幕设置按钮(Tune 图标)
- 弹幕设置面板:底部滑出,含弹幕总开关、透明度滑块、字体大小滑块、速度滑块、显示区域选择、类型屏蔽
- 同步机制:200ms 位置轮询,
currentPositionMs驱动弹幕引擎按时间轴渲染;发送的弹幕实时插入列表 - B站主题色:#FB7299
看剧AI片源集成(kanju1.com)
逆向分析看剧AI APP(WebView 混合应用),提取其 API 接口和播放逻辑,集成到 momo 作为第三个片源 Tab。
逆向分析
- APP 架构:WebView 混合应用(包名
com.kanjuai),前端https://kanju1.com/,原生层通过AiMovieAndroidBridge.postMessage()JS bridge 通信 - 前端 JS 抓取:从 kanju1.com 下载了 10+ 个 Vite 打包的 JS 模块(index/api/player-api/native-playback-controller/playback-client-runtime 等)
- API 签名破解:从
movie-card-runtime-DWvo8FrO.js中找到 HMAC-SHA256 签名逻辑- 密钥:
557d0e4ae929f438da6bd84412374e6086b8af09b3fed54bf22601d5bf8c54a0 - 签名内容:
METHOD
pathname+search
timestamp
nonce - 请求头:
x-ai-movie-timestamp,x-ai-movie-nonce,x-ai-movie-signature
- 密钥:
- 播放解析破解:从
yjplayer-artplayer-bundle.js中找到 resolve 端点- URL:
https://player.baipiaozhe.com/v1/playback/resolve/{token}(无需签名/cookie,公开 API) - 返回 JSON 包含实际 m3u8 直链,可直接播放
- URL:
API 调用链路
1. 匿名登录 POST /v1/users/anonymous (HMAC签名) → session cookie
2. 搜索 GET /v1/suggest?q=xxx (HMAC签名+cookie) → variant_id 列表
3. 详情 GET /v1/catalog/{variantId}/detail (HMAC签名+cookie) → 标题/演员/简介
4. 剧集列表 GET /v1/catalog/{variantId}/episodes (HMAC签名+cookie) → episode token 列表
5. 播放解析 GET player.baipiaozhe.com/v1/playback/resolve/{token} (无需签名) → m3u8 直链
6. 播放 m3u8 直链可直接播放(无需 Referer/cookie)
集成代码
- 新建
data/kanjuai/KanjuAiModels.kt:数据模型(搜索建议/详情/剧集/解析响应/匿名登录) - 新建
data/kanjuai/KanjuAiApi.kt:API 封装(HMAC 签名 + OkHttp cookie 管理 + resolvePlayback 懒解析)signRequest():HMAC-SHA256 签名,生成 timestamp/nonce/signature 三元组ensureSession():懒初始化匿名 session(首次 API 调用时自动登录)resolvePlayback(token):调用 player.baipiaozhe.com 解析 YJ-token → m3u8 URL
- 新建
viewmodel/KanjuAiViewModel.kt:搜索状态管理 + resolveDetail - 新建
ui/screens/KanjuAiScreen.kt:看剧AI Tab UI(搜索栏 + 结果网格) - 修改
ui/nav/Screen.kt:添加 KanjuAi 路由 + Detail 路由新增 kanjuai 参数 - 修改
ui/nav/AppNavHost.kt:添加看剧AI 底部 Tab + composable 路由 - 修改
viewmodel/DetailViewModel.kt:resolvePlayUrl()新增 YJ- token 解析分支(与 NBY 懒解密模式一致) - 修改
ui/screens/DetailScreen.kt:播放 URL 处理新增 YJ- token 失败提示 + HlsMediaSource 构建
设计要点
- 懒解析模式:episode URL 存储 token(如
YJ-1bc3dcbf92c1984b1784),播放时通过resolvePlayback(token)解析为 m3u8 直链,与速搜 NBY 加密地址模式一致 - HlsMediaSource:resolve 返回的 m3u8 URL 无
.m3u8后缀,DefaultMediaSourceFactory 按 MIME 推断会失败,需显式构建 HlsMediaSource - Cookie 管理:OkHttpClient 使用内存 ConcurrentHashMap 存储 cookie,不需持久化(每次启动重新匿名登录)
- 底部导航栏:6 个 Tab — 首页、速搜、看剧AI、分类、下载、我的
遇到的坑
nginx http2 指令不兼容
Ubuntu 版本的 nginx 不支持 http2 on 新语法,需要用 listen 443 ssl http2; 旧语法,或直接删除 http2 指令。
ProfileScreen 编译错误
编辑 ProfileScreen 时误删了 Box( 函数调用的左括号,导致编译失败。手动检查并补回。
GuestContent 引用 AuthenticatedContent 变量
更新弹窗的状态变量(isCheckingUpdate, updateInfo 等)定义在 AuthenticatedContent 函数内部,GuestContent 引用时找不到。需要在 GuestContent 中也声明相同的局部状态变量和更新对话框。
看剧AI详情页打不开(提示”未找到影片信息”)
搜索能出结果,但点进去报”未找到影片信息”。根因在 AppNavHost.kt 的 Detail composable:路由只声明了 susou 参数,没有声明 kanjuai。看剧AI 点击时虽然 createRoute(video.id, kanjuai = true) 传了参数,但 composable 里没读、也没从 KanjuAiNavHolder 取数据,externalVideo 为 null,于是走自家 CMS API 拿看剧AI 的 videoId(variantId 哈希)去查,自然查不到。
修复:Detail composable 增加 navArgument("kanjuai") 声明,isKanjuAi 分支从 KanjuAiNavHolder.take(videoId) 取外部片源注入。
看剧AI 剧集全部被过滤(无线路可播)
DetailViewModel.injectExternal() 的线路过滤条件只保留 http/NBY- 前缀的剧集 URL,而看剧AI 的剧集 URL 存的是 YJ- token(懒解析模式),全被丢弃,导致线路为空、无法播放。
修复:过滤条件增加 url.startsWith("YJ-"),播放时由 resolvePlayUrl() 懒解析为 m3u8 直链。
项目结构
/opt/ysxq/
|-- app/src/main/java/com/momo/app/
| |-- App.kt # Application 入口
| |-- data/
| | |-- auth/
| | | |-- AuthModels.kt # User 模型
| | | |-- AuthRepository.kt # 认证仓库 (JWT)
| | | |-- MacCmsAuthApi.kt # 苹果CMS 认证接口
| | | `-- MacCmsAuthModels.kt # JWT 请求/响应模型
| | |-- local/
| | | `-- UserPreferences.kt # DataStore 本地存储
| | |-- update/
| | | `-- UpdateChecker.kt # 在线更新检查
| | |-- ApiService.kt # CMS 视频 API
| | |-- NetworkModule.kt # Retrofit 网络
| | `-- ...
| |-- ui/
| | `-- screens/
| | |-- AuthScreen.kt # 登录/注册
| | |-- ProfileScreen.kt # 个人中心 + 检查更新
| | |-- FeedbackScreen.kt # 意见反馈
| | |-- AboutScreen.kt # 关于
| | |-- DetailScreen.kt # 详情 + 播放器
| | `-- ...
| |-- viewmodel/
| | |-- AuthViewModel.kt # 登录/注册 ViewModel
| | `-- ...
| `-- ...
|-- publish.sh # 一键发布脚本
|-- app/build.gradle.kts # versionCode/versionName
`-- app/build/outputs/apk/debug/
`-- app-debug.apk # 编译产物
/www/wwwroot/momo-release/
|-- update.json # 版本信息
|-- latest.apk # 最新 APK
`-- momo-153.apk # 版本归档
/etc/nginx/sites-available/
`-- momo.7998888.xyz # nginx 配置
版本历史
| 版本 | versionCode | 更新内容 |
|---|---|---|
| v1.5.0 | 150 | 进度条拖拽优化、首页分类修复 |
| v1.5.1 | 151 | 用户名密码登录注册、永久VIP |
| v1.5.2 | 152 | 自建在线更新分发系统、DNS修复 |
| v1.5.3 | 153 | 修复意见反馈邮箱 |
| v1.5.4 | 154 | 会员系统接入苹果CMS(JWT 认证,与网站同一套用户体系) |
| v1.5.5 | 155 | 画中画 PiP、自动下一集、倍速记忆、下拉刷新、缓冲进度条、字幕轨道、ExoPlayer 深度优化(media3 1.6.1) |
| v1.5.6 | 156 | B站风格弹幕播放器(纯 Compose 自绘弹幕引擎)、L站账号登录(WebView OAuth) |
| v1.5.7 | 157 | 看剧AI片源集成(kanju1.com API 逆向 + HMAC-SHA256 签名 + 播放线路解析);修复详情页打不开(未找到影片信息)与剧集无线路问题 |
下载
App 内也可通过 个人中心 – 检查更新 直接下载安装。
源码下载
完整源码(含 Kotlin 源文件、Gradle 配置、发布脚本)已打包上传:
解压后用 Android Studio 打开即可编译。需要 Android SDK 36 (compileSdk),minSdk 24。
当前版本 v157 (versionCode 15700) | 发布于 2026-08-14



暂无评论内容