MOMO 视频 App 开发全记录:播放器优化、用户系统、自建在线更新分发-7998888
MOMO App 源码下载

MOMO 视频 App 完整源码包(Kotlin + Jetpack Compose),包含播放器、投屏、用户系统等全部模块。

购买后可永久下载,支持后续版本更新。

19.9
立即购买
您当前未登录!建议登陆后购买,可保存购买订单
购买后即可下载源码压缩包。
付费资源

MOMO 视频 App 开发全记录:播放器优化、用户系统、自建在线更新分发

项目简介

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.phplabel_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,无 token is_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 直链,可直接播放

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.ktresolvePlayUrl() 新增 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 签名 + 播放线路解析);修复详情页打不开(未找到影片信息)与剧集无线路问题

下载

下载最新版 APK

App 内也可通过 个人中心 – 检查更新 直接下载安装。

源码下载

完整源码(含 Kotlin 源文件、Gradle 配置、发布脚本)已打包上传:

下载源码 (1.4MB tar.gz)

解压后用 Android Studio 打开即可编译。需要 Android SDK 36 (compileSdk),minSdk 24。

当前版本 v157 (versionCode 15700) | 发布于 2026-08-14

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容