This commit is contained in:
haomingming
2026-11-09 10:29:17 +08:00
parent 38d2d7de77
commit dcc4606b7c
4 changed files with 161 additions and 25 deletions
@@ -144,10 +144,12 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
@Volatile private var contentFilterActive: Boolean = false
/**
* 已切到原生 / 外部播放器、网页视频被暂停:期间拒绝网页的子资源请求(shouldInterceptRequest 在后台线程读取)。
* 已切到原生 / 外部播放器或退到后台、网页视频被暂停:期间拒绝网页的子资源请求(shouldInterceptRequest 在后台线程读取)。
* 网页播放器暂停后仍会继续缓冲分片(部分站点还带 P2P 加速),会和原生播放器抢网速;回到本页或打开新页面时解除。
*/
@Volatile private var webMediaSuspended: Boolean = false
/** 当前 Activity 是否处于前台可见状态(onResume ~ onPause),供后台网络拦截与页面完成回调判断是否立即暂停网页媒体 */
@Volatile private var isActivityResumed: Boolean = false
/** 防跳转劫持:用户最近一次真实点击的链接与时间(JS 线程写入,UI 线程读取) */
@Volatile private var lastUserLink: String? = null
@@ -316,10 +318,10 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
request: WebResourceRequest?
): WebResourceResponse? {
val reqUrl = request?.url?.toString() ?: return null
// 原生 / 外部播放器播放中:网页不再加载任何子资源(分片、清单刷新、P2P 回源、广告轮询),把网速留给播放器
// 原生 / 外部播放器播放中或已退到后台:网页不再加载任何子资源(分片、清单刷新、P2P 回源、广告轮询),把网速留给播放器或避免后台偷跑
if (request.isForMainFrame) {
webMediaSuspended = false
} else if (webMediaSuspended) {
if (isActivityResumed) webMediaSuspended = false
} else if (webMediaSuspended || !isActivityResumed) {
return VideoSnifferHelper.createRejectedResponse()
}
// 0. 记录请求头,原生播放器回放时使用(Referer / UA 等)
@@ -376,6 +378,10 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
// 强力过滤:隐藏可疑动图 / 色情博彩广告
updateContentFilterState(url)
if (contentFilterActive) view?.evaluateJavascript(ContentFilterHelper.JS_CONTENT_FILTER, null)
if (!isActivityResumed) {
suspendWebMedia()
view?.onPause()
}
}
override fun onPageCommitVisible(view: WebView?, url: String?) {
@@ -385,6 +391,10 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
injectDesktopModeFallback(view)
// 新页面首帧可见时再注入一次,尽量在广告图显示前隐藏
if (contentFilterActive) view?.evaluateJavascript(ContentFilterHelper.JS_CONTENT_FILTER, null)
if (!isActivityResumed) {
suspendWebMedia()
view?.onPause()
}
}
override fun onPageFinished(view: WebView?, url: String?) {
@@ -404,6 +414,10 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
view?.evaluateJavascript(VideoSnifferHelper.JS_SITE_ADAPTER_MACCMS, null)
url?.let { injectUserScripts(it) }
url?.let { historyRepository.add(it, currentPageTitle.ifEmpty { it }) }
if (!isActivityResumed) {
suspendWebMedia()
view?.onPause()
}
}
@Deprecated("Deprecated in Java")
@@ -2554,8 +2568,12 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
override fun onResume() {
super.onResume()
// 从原生 / 外部播放器回来:恢复网页加载
if (::webView.isInitialized) resumeWebMediaIfSuspended()
isActivityResumed = true
// 从后台或原生 / 外部播放器回来:恢复 WebView 与网页加载
if (::webView.isInitialized) {
webView.onResume()
resumeWebMediaIfSuspended()
}
// 从播放器返回首页时刷新追剧列表(进度 / 下一集)
if (isOnHomePage && ::binding.isInitialized) updateContinueWatchingBanner()
// 自愈:回到前台时重新计算光标显示状态
@@ -2563,10 +2581,25 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
}
override fun onPause() {
isActivityResumed = false
cancelAutoPlay(markHandled = true)
if (::webView.isInitialized) {
suspendWebMedia()
webView.onPause()
}
super.onPause()
}
override fun onStop() {
isActivityResumed = false
cancelAutoPlay(markHandled = true)
if (::webView.isInitialized) {
suspendWebMedia()
webView.onPause()
}
super.onStop()
}
@Deprecated("Deprecated in Java")
override fun onBackPressed() {
// 自动接管播放倒计时中:返回键只取消倒计时
@@ -94,6 +94,8 @@ class PlayerActivity : AppCompatActivity() {
/** 当前片源是否已做过片头检查 / 已触发片尾 */
private var introChecked = false
private var outroTriggered = false
/** 是否已退到后台(按 Home 键等触发 onPause / onStop,非画中画状态):防止后台异步解析完成后自动出声播放 */
private var isInBackground = false
/** 下一集预解析:独立的后台解析器(VideoResolver 一个实例同时只能解析一个页面) */
private var preloadResolver: VideoResolver? = null
@@ -250,6 +252,8 @@ class PlayerActivity : AppCompatActivity() {
private fun initPlayer() {
val p = ExoPlayer.Builder(this, createRenderersFactory())
.setLoadControl(PlayerTuning.buildLoadControl())
.setAudioAttributes(androidx.media3.common.AudioAttributes.DEFAULT, true)
.setHandleAudioBecomingNoisy(true)
.build()
p.addAnalyticsListener(diagnostics)
binding.playerView.player = p
@@ -407,16 +411,20 @@ class PlayerActivity : AppCompatActivity() {
p.setMediaSource(mediaSource, startPos)
p.setPlaybackSpeed(speeds[currentSpeedIndex])
p.prepare()
p.playWhenReady = true
val canPlayNow = !isInBackground
p.playWhenReady = canPlayNow
if (!canPlayNow) p.pause()
if (carried <= 0 && record != null && startPos > 0) showFeedback("已自动续播至 ${record.formatPosition()}")
Log.d(TAG, "play: ${source.url} hls=${source.isHls} headers=${source.headers.keys}")
Log.d(TAG, "play: ${source.url} hls=${source.isHls} canPlayNow=$canPlayNow headers=${source.headers.keys}")
lastSavedSec = 0
suppressSave = false
introChecked = false
outroTriggered = false
mainHandler.removeCallbacks(updateProgressRunnable)
mainHandler.post(updateProgressRunnable)
if (canPlayNow) mainHandler.post(updateProgressRunnable)
updatePlayPauseButtonText()
updatePauseOverlay()
updateEpisodeUi()
showOsdTemporarily()
}
@@ -456,7 +464,11 @@ class PlayerActivity : AppCompatActivity() {
private fun handlePlayerError(error: PlaybackException) {
binding.playerLoading.visibility = View.GONE
val cause = PlayerSsl.rootCauseText(error)
Log.e(TAG, "playback error: ${error.errorCodeName} cause=$cause", error)
Log.e(TAG, "playback error: ${error.errorCodeName} cause=$cause inBg=$isInBackground", error)
if (isInBackground) {
pauseForBackground()
return
}
val page = currentPageUrl
// 证书 / SSL 握手失败:网页会忽略证书错误继续播,原生播放器这里同样放宽后重试一次
if (PlayerSsl.isSslError(error) && PlayerSsl.enableLenient()) {
@@ -711,6 +723,7 @@ class PlayerActivity : AppCompatActivity() {
}
private fun maybeSkipOutro(current: Long, duration: Long) {
if (isInBackground || player?.isPlaying != true) return
if (outroTriggered || duration <= 0 || !hasNextEpisode()) return
val outro = AppSettings.getSkip(this, skipKey()).outroMs
if (outro <= 0 || outro >= duration) return
@@ -1209,17 +1222,19 @@ class PlayerActivity : AppCompatActivity() {
override fun onUserLeaveHint() {
super.onUserLeaveHint()
val inPip = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInPictureInPictureMode
if (!inPip) pauseForBackground()
if (!inPip) {
isInBackground = true
pauseForBackground()
}
}
/** 切到后台时暂停播放,并同步按钮文字 / 暂停浮层,回到前台时保持暂停状态 */
private fun pauseForBackground() {
val p = player ?: return
if (p.playWhenReady) {
p.pause()
updatePlayPauseButtonText()
updatePauseOverlay()
}
p.playWhenReady = false
p.pause()
updatePlayPauseButtonText()
updatePauseOverlay()
saveCurrentProgress()
}
@@ -1431,17 +1446,30 @@ class PlayerActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
isInBackground = false
hideSystemUI()
if (currentSource != null) {
mainHandler.removeCallbacks(updateProgressRunnable)
mainHandler.post(updateProgressRunnable)
}
}
override fun onPause() {
super.onPause()
val inPip = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) isInPictureInPictureMode else false
if (!inPip) pauseForBackground() else saveCurrentProgress()
if (!inPip) {
isInBackground = true
pauseForBackground()
} else {
saveCurrentProgress()
}
}
override fun onStop() {
super.onStop()
isInBackground = true
mainHandler.removeCallbacks(updateProgressRunnable)
cancelPreload()
// 兜底:任何情况下界面完全不可见(含关闭画中画小窗)都暂停,避免后台继续出声
pauseForBackground()
}
@@ -325,13 +325,20 @@ object VideoSnifferHelper {
};
}
} catch (e) {}
// 记录页面里的 hls.js 实例:切到原生播放器时 stopLoad(),否则网页播放器暂停后仍会在后台继续缓冲分片,和原生播放器抢网速
// 记录页面里的 hls.js 实例:切到原生播放器或退到后台时 stopLoad(),否则网页播放器暂停后仍会在后台继续缓冲分片,和原生播放器抢网速
var hlsList = [];
var mediaSuspended = false;
function trackHls(h) {
try {
if (!h || hlsList.indexOf(h) >= 0) return;
hlsList.push(h);
if (hlsList.length > 20) hlsList.shift();
if (!h) return;
if (hlsList.indexOf(h) < 0) {
hlsList.push(h);
if (hlsList.length > 20) hlsList.shift();
}
if (mediaSuspended && !h.__tvSuspended && typeof h.stopLoad === 'function') {
h.stopLoad();
h.__tvSuspended = true;
}
} catch (e) {}
}
function wrapHls(H) {
@@ -372,6 +379,27 @@ object VideoSnifferHelper {
});
}
} catch (e) {}
try {
var origPlay = HTMLMediaElement.prototype.play;
if (typeof origPlay === 'function') {
HTMLMediaElement.prototype.play = function() {
if (mediaSuspended) {
try { this.pause(); } catch (_) {}
return window.Promise ? Promise.resolve() : undefined;
}
return origPlay.apply(this, arguments);
};
}
function onMediaPlay(ev) {
if (!mediaSuspended) return;
var t = ev && ev.target;
if (t && typeof t.pause === 'function') {
try { t.pause(); } catch (_) {}
}
}
document.addEventListener('play', onMediaPlay, true);
document.addEventListener('playing', onMediaPlay, true);
} catch (e) {}
try {
window.addEventListener('message', function(e) {
try {
@@ -379,6 +407,7 @@ object VideoSnifferHelper {
if (!data || (data.__tvcmd !== 'pause' && data.__tvcmd !== 'resume')) return;
var i;
if (data.__tvcmd === 'pause') {
mediaSuspended = true;
var vs = document.querySelectorAll('video,audio');
for (i = 0; i < vs.length; i++) { try { vs[i].pause(); } catch (_) {} }
// 暂停后 hls.js 仍会缓冲到上限(不少站点设到几分钟),停止加载,回到网页时再恢复
@@ -386,6 +415,7 @@ object VideoSnifferHelper {
try { if (!hlsList[i].__tvSuspended) { hlsList[i].stopLoad(); hlsList[i].__tvSuspended = true; } } catch (_) {}
}
} else {
mediaSuspended = false;
for (i = 0; i < hlsList.length; i++) {
try { if (hlsList[i].__tvSuspended) { hlsList[i].__tvSuspended = false; hlsList[i].startLoad(); } } catch (_) {}
}
@@ -408,7 +438,13 @@ object VideoSnifferHelper {
for (var k = 0; k < fs.length; k++) { try { pauseIn(fs[k].contentDocument); } catch (e) {} }
}
try { pauseIn(document); } catch (e) {}
try { window.postMessage({ __tvcmd: 'pause' }, '*'); } catch (e) {}
var cmd = { __tvcmd: 'pause' };
try { window.postMessage(cmd, '*'); } catch (e) {}
try {
for (var j = 0; j < window.frames.length; j++) {
try { window.frames[j].postMessage(cmd, '*'); } catch (e) {}
}
} catch (e) {}
})();
""".trimIndent()
@@ -1,9 +1,8 @@
package com.tv.browser
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.webkit.WebSettings
import android.view.ViewGroup
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
import com.tv.browser.databinding.ActivityVideoWebBinding
@@ -13,10 +12,12 @@ import com.tv.browser.databinding.ActivityVideoWebBinding
*/
class VideoWebActivity : AppCompatActivity() {
private lateinit var binding: ActivityVideoWebBinding
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityVideoWebBinding.inflate(layoutInflater)
binding = ActivityVideoWebBinding.inflate(layoutInflater)
setContentView(binding.root)
val url = intent.getStringExtra(EXTRA_VIDEO_URL) ?: ""
@@ -59,6 +60,44 @@ class VideoWebActivity : AppCompatActivity() {
)
}
override fun onResume() {
super.onResume()
if (::binding.isInitialized) {
binding.webView.onResume()
}
}
override fun onPause() {
if (::binding.isInitialized) {
try {
binding.webView.evaluateJavascript(VideoSnifferHelper.JS_PAUSE_ALL_MEDIA, null)
binding.webView.onPause()
} catch (_: Exception) { }
}
super.onPause()
}
override fun onStop() {
if (::binding.isInitialized) {
try {
binding.webView.evaluateJavascript(VideoSnifferHelper.JS_PAUSE_ALL_MEDIA, null)
binding.webView.onPause()
} catch (_: Exception) { }
}
super.onStop()
}
override fun onDestroy() {
if (::binding.isInitialized) {
try {
binding.webView.stopLoading()
(binding.webView.parent as? ViewGroup)?.removeView(binding.webView)
binding.webView.destroy()
} catch (_: Exception) { }
}
super.onDestroy()
}
private fun escapeHtmlAttr(s: String): String {
return s.replace("&", "&amp;")
.replace("\"", "&quot;")