From e8883122a2f5d3f6a31752008c5689b86a5eecbe Mon Sep 17 00:00:00 2001 From: TinyHai <34483077+TinyHai@users.noreply.github.com> Date: Fri, 17 Jan 2025 19:39:27 +0800 Subject: [PATCH] feat: live quality (#1615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加设置直播默认清晰度 --- .../me/iacn/biliroaming/BiliBiliPackage.kt | 62 ++++++++ .../java/me/iacn/biliroaming/XposedInit.kt | 1 + .../iacn/biliroaming/hook/LiveQualityHook.kt | 147 ++++++++++++++++++ .../proto/me/iacn/biliroaming/configs.proto | 16 ++ app/src/main/res/values/arrays.xml | 20 +++ app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/prefs_setting.xml | 8 + 7 files changed, 256 insertions(+) create mode 100644 app/src/main/java/me/iacn/biliroaming/hook/LiveQualityHook.kt diff --git a/app/src/main/java/me/iacn/biliroaming/BiliBiliPackage.kt b/app/src/main/java/me/iacn/biliroaming/BiliBiliPackage.kt index cc7570a1d8..38aa5d4cef 100644 --- a/app/src/main/java/me/iacn/biliroaming/BiliBiliPackage.kt +++ b/app/src/main/java/me/iacn/biliroaming/BiliBiliPackage.kt @@ -5,6 +5,7 @@ package me.iacn.biliroaming import android.app.AndroidAppHelper import android.content.Context import android.content.SharedPreferences +import android.net.Uri import android.text.style.ClickableSpan import android.text.style.LineBackgroundSpan import android.util.SparseArray @@ -168,6 +169,8 @@ class BiliBiliPackage constructor(private val mClassLoader: ClassLoader, mContex val playSpeedManager by Weak { mHookInfo.playSpeedManager from mClassLoader } val continuationClass by Weak { mHookInfo.continuation.class_ from mClassLoader } val vipQualityTrialService by Weak { mHookInfo.vipQualityTrialService.class_ from mClassLoader } + val livePlayUrlSelectUtilClass by Weak { mHookInfo.liveQuality.selectUtil.class_ from mClassLoader } + val liveRTCSourceServiceImplClass by Weak { mHookInfo.liveQuality.sourceService.class_ from mClassLoader } // for v8.17.0+ val useNewMossFunc = instance.viewMossClass?.declaredMethods?.any { @@ -332,6 +335,10 @@ class BiliBiliPackage constructor(private val mClassLoader: ClassLoader, mContex fun onFeedClicked() = mHookInfo.cardClickProcessor.onFeedClicked.orNull + fun parseUriMethod() = mHookInfo.liveQuality.selectUtil.parseUri.orNull + + fun switchAutoMethod() = mHookInfo.liveQuality.sourceService.switchAuto.orNull + private fun readHookInfo(context: Context): Configs.HookInfo { try { val hookInfoFile = File(context.cacheDir, Constant.HOOK_INFO_FILE_NAME) @@ -2125,6 +2132,61 @@ class BiliBiliPackage constructor(private val mClassLoader: ClassLoader, mContex class_ = class_ { name = serviceClass.name } canTrial = method { name = canTrialMethod.name } } + liveQuality = liveQuality { + val utilClass = dexHelper.findMethodUsingString( + "select 秒开 play url --codec:", + false, + -1, + -1, + null, + -1, + null, + null, + null, + true + ).map { + dexHelper.decodeMethodIndex(it)?.declaringClass + }.firstOrNull() ?: return@liveQuality + val selectorDataClass = dexHelper.findMethodUsingString( + "LiveUrlSelectorData(playUrl=", + false, + -1, + -1, + null, + -1, + null, + null, + null, + true + ).map { + dexHelper.decodeMethodIndex(it)?.declaringClass + }.firstOrNull() ?: return@liveQuality + val parseUriMethod = utilClass.declaredMethods.firstOrNull { + it.returnType == selectorDataClass && it.parameterCount == 1 && it.parameterTypes[0] == Uri::class.java + } ?: return@liveQuality + val switchAutoMethod = dexHelper.findMethodUsingString( + "switchAuto ", + false, + -1, + -1, + null, + -1, + null, + null, + null, + true + ).map { + dexHelper.decodeMethodIndex(it) + }.firstOrNull() ?: return@liveQuality + selectUtil = livePlayUrlSelectUtil { + class_ = class_ { name = utilClass.name } + parseUri = method { name = parseUriMethod.name } + } + sourceService = liveRTCSourceServiceImpl { + class_ = class_ { name = switchAutoMethod.declaringClass.name } + switchAuto = method { name = switchAutoMethod.name } + } + } dexHelper.close() } diff --git a/app/src/main/java/me/iacn/biliroaming/XposedInit.kt b/app/src/main/java/me/iacn/biliroaming/XposedInit.kt index e5118247e1..b7b3a965c5 100644 --- a/app/src/main/java/me/iacn/biliroaming/XposedInit.kt +++ b/app/src/main/java/me/iacn/biliroaming/XposedInit.kt @@ -122,6 +122,7 @@ class XposedInit : IXposedHookLoadPackage, IXposedHookZygoteInit { startHook(UposReplaceHook(lpparam.classLoader)) startHook(SpeedHook(lpparam.classLoader)) startHook(MultiWindowHook(lpparam.classLoader)) + startHook(LiveQualityHook(lpparam.classLoader)) } lpparam.processName.endsWith(":web") -> { diff --git a/app/src/main/java/me/iacn/biliroaming/hook/LiveQualityHook.kt b/app/src/main/java/me/iacn/biliroaming/hook/LiveQualityHook.kt new file mode 100644 index 0000000000..1e418b9f81 --- /dev/null +++ b/app/src/main/java/me/iacn/biliroaming/hook/LiveQualityHook.kt @@ -0,0 +1,147 @@ +package me.iacn.biliroaming.hook + +import android.net.Uri +import me.iacn.biliroaming.BiliBiliPackage.Companion.instance +import me.iacn.biliroaming.utils.* +import org.json.JSONArray +import org.json.JSONObject + +class LiveQualityHook(classLoader: ClassLoader) : BaseHook(classLoader) { + + override fun startHook() { + val liveQuality = sPrefs.getString("live_quality", "0")?.toIntOrNull() ?: 0 + if (liveQuality <= 0) { + return + } + + val canSwitchLiveRoom = !sPrefs.getBoolean("forbid_switch_live_room", false) + + instance.retrofitResponseClass?.hookBeforeAllConstructors { param -> + val url = getRetrofitUrl(param.args[0]) ?: return@hookBeforeAllConstructors + val body = param.args[1] ?: return@hookBeforeAllConstructors + + when { + instance.generalResponseClass?.isInstance(body) != true -> Unit + // 处理上下滑动切换直播间 + url.startsWith("https://api.live.bilibili.com/xlive/app-interface/v2/room/recList?") && canSwitchLiveRoom -> { + val data = body.getObjectField("data") ?: return@hookBeforeAllConstructors + val info = JSONObject(instance.fastJsonClass?.callStaticMethod("toJSONString", data).toString()) + if (fixLiveRoomFeedInfo(info, liveQuality)) { + body.setObjectField( + "data", + instance.fastJsonClass?.callStaticMethod(instance.fastJsonParse(), info.toString(), data.javaClass) + ) + } + } + url.startsWith("https://api.live.bilibili.com/xlive/app-room/v2/index/getRoomPlayInfo?") -> { + val uri = Uri.parse(url) + val reqQn = uri.getQueryParameter("qn") + if (!reqQn.isNullOrEmpty() && reqQn != "0") { + return@hookBeforeAllConstructors + } + val data = body.getObjectField("data") ?: return@hookBeforeAllConstructors + val info = JSONObject(instance.fastJsonClass?.callStaticMethod("toJSONString", data).toString()) + if (fixRoomPlayInfo(info, liveQuality)) { + body.setObjectField( + "data", + instance.fastJsonClass?.callStaticMethod(instance.fastJsonParse(), info.toString(), data.javaClass) + ) + } + } + } + } + + instance.liveRTCSourceServiceImplClass?.hookBeforeAllMethods(instance.switchAutoMethod()) { param -> + val mode = param.args[0] as? Enum<*> ?: return@hookBeforeAllMethods + if (mode.ordinal == 2) { // AUTO + param.result = null + } + } + + instance.livePlayUrlSelectUtilClass?.hookBeforeMethod( + instance.parseUriMethod(), + Uri::class.java + ) { param -> + val originalUri = param.args[0] as Uri + if (!originalUri.isLive()) { + return@hookBeforeMethod + } + + val newQuality = findQuality( + JSONArray(originalUri.getQueryParameter("accept_quality")), + liveQuality + ).toString() + + param.args[0] = originalUri.replaceQuery { name, oldVal -> + when { + "current_qn" == name -> newQuality + "current_quality" == name -> newQuality + name.endsWith("current_qn") -> "0" + else -> oldVal + } + } + } + } + + private fun Uri.isLive(): Boolean { + return scheme in arrayOf("http", "https") + && host == "live.bilibili.com" + && pathSegments.firstOrNull()?.all { it.isDigit() } == true + } + + private fun findQuality(acceptQuality: JSONArray, expectQuality: Int): Int { + val acceptQnList = acceptQuality.asSequence().sorted().toList() + val max = acceptQnList.max() + val min = acceptQnList.min() + return when { + expectQuality > max -> max + expectQuality < min -> min + else -> acceptQnList.first { it >= expectQuality } + } + } + + private fun Uri.replaceQuery(replacer: (String, String) -> String?): Uri { + val newBuilder = buildUpon().clearQuery() + for (name in queryParameterNames) { + val newValue = replacer(name, getQueryParameter(name) ?: "") + newValue?.let { newBuilder.appendQueryParameter(name, it) } + } + return newBuilder.build() + } + + private fun fixLiveRoomFeedInfo(info: JSONObject, expectQuality: Int): Boolean { + val feedList = info.optJSONArray("list") ?: return false + feedList.iterator().forEach { feedData -> + val newQuality = findQuality(feedData.getJSONArray("accept_quality"), expectQuality) + feedData.put("current_qn", newQuality) + feedData.put("current_quality", newQuality) + } + return true + } + + private fun fixRoomPlayInfo(info: JSONObject, expectQuality: Int): Boolean { + val playUrlInfo = info.optJSONObject("playurl_info") ?: return false + val playUrlObj = playUrlInfo.getJSONObject("playurl") + playUrlObj.getJSONArray("stream").iterator().forEach { stream -> + stream.getJSONArray("format").iterator().forEach { format -> + format.getJSONArray("codec").iterator().asSequence().run { + firstOrNull { codec -> fixCodec(codec, expectQuality, true) } + ?: firstOrNull { codec -> fixCodec(codec, expectQuality, false) } + } + } + } + return true + } + + private fun fixCodec(codec: JSONObject, expectQuality: Int, strict: Boolean): Boolean { + val newQuality = findQuality(codec.getJSONArray("accept_qn"), expectQuality) + if (strict && newQuality != expectQuality) { + return false + } + val oldQn = codec.getInt("current_qn") + if (oldQn != newQuality) { + codec.put("current_qn", newQuality) + } + return true + } +} \ No newline at end of file diff --git a/app/src/main/proto/me/iacn/biliroaming/configs.proto b/app/src/main/proto/me/iacn/biliroaming/configs.proto index 018f89bd54..9e698225fe 100644 --- a/app/src/main/proto/me/iacn/biliroaming/configs.proto +++ b/app/src/main/proto/me/iacn/biliroaming/configs.proto @@ -275,6 +275,21 @@ message VipQualityTrialService { optional Method canTrial = 2; } +message LivePlayUrlSelectUtil { + optional Class class = 1; + optional Method parseUri = 2; +} + +message LiveRTCSourceServiceImpl { + optional Class class = 1; + optional Method switchAuto = 2; +} + +message LiveQuality { + optional LivePlayUrlSelectUtil selectUtil = 1; + optional LiveRTCSourceServiceImpl sourceService = 2; +} + message HookInfo { int64 last_update_time = 1; optional MapIds map_ids = 2; @@ -340,4 +355,5 @@ message HookInfo { optional QualityStrategyProvider qualityStrategyProvider = 95; optional Continuation continuation = 96; optional VipQualityTrialService vipQualityTrialService = 97; + optional LiveQuality liveQuality = 98; } diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 63dc6c4a02..bc23aecd6a 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -269,6 +269,26 @@ 120 127 + + 默认 + 流畅 + 高清 + 超清 + 蓝光 + 原画 + 4K + 杜比 + + + 0 + 80 + 150 + 250 + 400 + 10000 + 20000 + 30000 + 默认 240P diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6cef43f220..9c93ae6c98 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -265,6 +265,8 @@ 设置视频半屏播放时的清晰度,设置后会大幅降低视频的首次播放加载速度,旧版播放器(半屏时进度条在框内)仅支持跟随全屏清晰度,需开启解锁番剧限制选项 视频全屏清晰度 设置视频全屏播放时默认的清晰度,当播放器画质选项为自动时此选项不生效,需开启解锁番剧限制选项 + 直播清晰度 + 设置进入直播间的默认清晰度,若不存在指定清晰度,则会取最接近的清晰度 屏蔽评论引导 屏蔽视频详情页及评论页的评论引导提示 禁止首页自动刷新 diff --git a/app/src/main/res/xml/prefs_setting.xml b/app/src/main/res/xml/prefs_setting.xml index 411edd02ea..ab0e777ac0 100644 --- a/app/src/main/res/xml/prefs_setting.xml +++ b/app/src/main/res/xml/prefs_setting.xml @@ -237,6 +237,14 @@ android:summary="@string/full_screen_quality_summary" android:title="@string/full_screen_quality_title" /> + +