From dcc4606b7c25d107e5b85d2190c2b5f143aa9fd4 Mon Sep 17 00:00:00 2001 From: haomingming Date: Mon, 9 Nov 2026 10:29:17 +0800 Subject: [PATCH] 4k --- .../java/com/tv/browser/BrowserActivity.kt | 45 ++++++++++++++--- .../java/com/tv/browser/PlayerActivity.kt | 50 +++++++++++++++---- .../java/com/tv/browser/VideoSnifferHelper.kt | 46 +++++++++++++++-- .../java/com/tv/browser/VideoWebActivity.kt | 45 +++++++++++++++-- 4 files changed, 161 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/tv/browser/BrowserActivity.kt b/app/src/main/java/com/tv/browser/BrowserActivity.kt index 8078dfa..5db53aa 100644 --- a/app/src/main/java/com/tv/browser/BrowserActivity.kt +++ b/app/src/main/java/com/tv/browser/BrowserActivity.kt @@ -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() { // 自动接管播放倒计时中:返回键只取消倒计时 diff --git a/app/src/main/java/com/tv/browser/PlayerActivity.kt b/app/src/main/java/com/tv/browser/PlayerActivity.kt index bc84b1e..7f1822b 100644 --- a/app/src/main/java/com/tv/browser/PlayerActivity.kt +++ b/app/src/main/java/com/tv/browser/PlayerActivity.kt @@ -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() } diff --git a/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt b/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt index e75e6f0..015ca43 100644 --- a/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt +++ b/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt @@ -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() diff --git a/app/src/main/java/com/tv/browser/VideoWebActivity.kt b/app/src/main/java/com/tv/browser/VideoWebActivity.kt index aac98b4..8769831 100644 --- a/app/src/main/java/com/tv/browser/VideoWebActivity.kt +++ b/app/src/main/java/com/tv/browser/VideoWebActivity.kt @@ -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("&", "&") .replace("\"", """)