From 227a39552d849a0ba5f66081b86ad3469ad7803b Mon Sep 17 00:00:00 2001 From: haomingming Date: Mon, 9 Nov 2026 11:24:40 +0800 Subject: [PATCH] 4k --- app/build.gradle.kts | 3 +- app/proguard-rules.pro | 15 + .../main/java/com/tv/browser/AdBlockHelper.kt | 26 +- .../main/java/com/tv/browser/AppSettings.kt | 19 + .../java/com/tv/browser/BookmarkRepository.kt | 51 +- .../java/com/tv/browser/BrowserActivity.kt | 2209 +++-------------- .../java/com/tv/browser/BrowserUiHelper.kt | 523 ++++ .../com/tv/browser/ContentFilterHelper.kt | 31 +- .../java/com/tv/browser/CursorController.kt | 233 ++ .../tv/browser/CustomViewFullscreenHelper.kt | 212 ++ .../java/com/tv/browser/HistoryRepository.kt | 64 +- .../tv/browser/PlaybackHistoryRepository.kt | 85 +- .../java/com/tv/browser/PlayerActivity.kt | 110 + .../com/tv/browser/RemoteControlHelper.kt | 204 ++ .../com/tv/browser/VideoSnifferController.kt | 286 +++ .../java/com/tv/browser/VideoSnifferHelper.kt | 29 +- .../com/tv/browser/VodSearchDialogHelper.kt | 138 + .../java/com/tv/browser/VodSearchHelper.kt | 229 +- 18 files changed, 2405 insertions(+), 2062 deletions(-) create mode 100644 app/src/main/java/com/tv/browser/BrowserUiHelper.kt create mode 100644 app/src/main/java/com/tv/browser/CursorController.kt create mode 100644 app/src/main/java/com/tv/browser/CustomViewFullscreenHelper.kt create mode 100644 app/src/main/java/com/tv/browser/RemoteControlHelper.kt create mode 100644 app/src/main/java/com/tv/browser/VideoSnifferController.kt create mode 100644 app/src/main/java/com/tv/browser/VodSearchDialogHelper.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bfd8afb..12b073a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -17,7 +17,8 @@ android { buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 07abe4d..acf9c90 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,6 +1,17 @@ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. + +# Keep attributes required for WebView JavascriptInterface & reflection +-keepattributes JavascriptInterface +-keepattributes *Annotation* +-keepattributes Signature, InnerClasses, EnclosingMethod + +# Keep all @JavascriptInterface methods across the app +-keepclassmembers class com.tv.browser.** { + @android.webkit.JavascriptInterface ; +} + # Keep WebView related classes -keepclassmembers class * extends android.webkit.WebViewClient { public void *(android.webkit.WebView, java.lang.String); @@ -9,3 +20,7 @@ -keepclassmembers class * extends android.webkit.WebChromeClient { public void *(android.webkit.WebView, java.lang.String); } + +# NanoHTTPD +-dontwarn fi.iki.elonen.** +-keep class fi.iki.elonen.** { *; } diff --git a/app/src/main/java/com/tv/browser/AdBlockHelper.kt b/app/src/main/java/com/tv/browser/AdBlockHelper.kt index 0e3140a..85ca7e4 100644 --- a/app/src/main/java/com/tv/browser/AdBlockHelper.kt +++ b/app/src/main/java/com/tv/browser/AdBlockHelper.kt @@ -2,7 +2,6 @@ package com.tv.browser import android.content.Context import android.net.Uri -import android.util.Log import android.webkit.WebResourceResponse import java.io.ByteArrayInputStream @@ -15,9 +14,10 @@ import java.io.ByteArrayInputStream */ object AdBlockHelper { - private const val TAG = "TVBrowser.AdBlock" private const val PREFS_NAME = "tv_browser_adblock" private const val KEY_ADBLOCK_ENABLED = "adblock_enabled" + @Volatile + private var cachedEnabled: Boolean? = null /** 常见广告与跟踪域名黑名单关键词 */ private val AD_HOST_PATTERNS = arrayOf( @@ -62,14 +62,16 @@ object AdBlockHelper { "intent" ) - /** 是否开启去广告(默认开启) */ + /** 是否开启去广告(默认开启,内存缓存免除 shouldInterceptRequest 高频读取 SharedPreferences) */ fun isEnabled(context: Context): Boolean { + cachedEnabled?.let { return it } val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getBoolean(KEY_ADBLOCK_ENABLED, true) + return prefs.getBoolean(KEY_ADBLOCK_ENABLED, true).also { cachedEnabled = it } } /** 设置去广告开关 */ fun setEnabled(context: Context, enabled: Boolean) { + cachedEnabled = enabled val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) prefs.edit().putBoolean(KEY_ADBLOCK_ENABLED, enabled).apply() } @@ -107,7 +109,7 @@ object AdBlockHelper { * 强力元素隐藏与弹窗防劫持注入脚本: * 1. 隐藏高层级遮罩、悬浮关闭广告、通用横幅容器 * 2. 覆盖 window.open,防止影视网站静默弹窗跳出 - * 3. MutationObserver 监听动态插入的广告节点 + * 3. MutationObserver 监听动态插入的广告节点(带 250ms 节流防抖,避免高频 DOM 变动导致低配电视重排卡顿) */ val JS_INJECT_ADBLOCK = """ (function() { @@ -138,20 +140,30 @@ object AdBlockHelper { style.appendChild(document.createTextNode(css)); (document.head || document.documentElement).appendChild(style); - // 3. 动态监听并隐藏常见弹层遮罩 + // 3. 动态监听并隐藏常见弹层遮罩(节流执行) function cleanPopups() { var popups = document.querySelectorAll('div[style*="z-index"], iframe[src*="ad"]'); for (var i = 0; i < popups.length; i++) { var el = popups[i]; + if (el.__tvAdCleaned) continue; var z = parseInt(window.getComputedStyle(el).zIndex) || 0; if (z >= 99999 && !el.querySelector('video')) { + el.__tvAdCleaned = true; el.style.setProperty('display', 'none', 'important'); } } } + var cleanTimer = null; + function scheduleClean() { + if (cleanTimer) return; + cleanTimer = setTimeout(function() { + cleanTimer = null; + cleanPopups(); + }, 250); + } cleanPopups(); try { - new MutationObserver(cleanPopups).observe(document.documentElement || document.body, { childList: true, subtree: true }); + new MutationObserver(scheduleClean).observe(document.documentElement || document.body, { childList: true, subtree: true }); } catch(e) {} } catch(e) {} })(); diff --git a/app/src/main/java/com/tv/browser/AppSettings.kt b/app/src/main/java/com/tv/browser/AppSettings.kt index 51ac9ef..505271d 100644 --- a/app/src/main/java/com/tv/browser/AppSettings.kt +++ b/app/src/main/java/com/tv/browser/AppSettings.kt @@ -5,6 +5,7 @@ import android.content.Context /** * 应用级小设置: * - 自动接管播放(嗅探到视频后倒计时自动进入原生播放器) + * - 播放倍速记忆 * - 按剧记住的片头 / 片尾时长(跳过片头片尾) */ object AppSettings { @@ -13,6 +14,8 @@ object AppSettings { private const val KEY_DECODER_MODE = "decoder_mode" private const val KEY_PRELOAD_NEXT = "preload_next" private const val KEY_PURIFY_PAGE = "purify_page" + private const val KEY_PLAYBACK_SPEED = "playback_speed" + private const val KEY_LENIENT_SSL = "lenient_ssl" private const val SKIP_PREFIX = "skip|" /** 解码兼容模式 */ @@ -53,6 +56,22 @@ object AppSettings { prefs(context).edit().putBoolean(KEY_PURIFY_PAGE, enabled).apply() } + /** 记忆的播放倍速(默认 1.0x) */ + fun getPlaybackSpeed(context: Context): Float = + prefs(context).getFloat(KEY_PLAYBACK_SPEED, 1.0f).coerceIn(0.5f, 3.0f) + + fun setPlaybackSpeed(context: Context, speed: Float) { + prefs(context).edit().putFloat(KEY_PLAYBACK_SPEED, speed.coerceIn(0.5f, 3.0f)).apply() + } + + /** 宽松 SSL 兼容模式(默认开启,兼容电视系统时间未同步与老旧根证书;关闭后仅对日期异常放行,严格拦截假证书) */ + fun isLenientSsl(context: Context): Boolean = + prefs(context).getBoolean(KEY_LENIENT_SSL, true) + + fun setLenientSsl(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean(KEY_LENIENT_SSL, enabled).apply() + } + /** 片头片尾设置:introMs = 片头结束位置;outroMs = 片尾长度(距结尾多少毫秒开始算片尾) */ data class SkipConfig(val introMs: Long, val outroMs: Long) { val isEmpty: Boolean get() = introMs <= 0 && outroMs <= 0 diff --git a/app/src/main/java/com/tv/browser/BookmarkRepository.kt b/app/src/main/java/com/tv/browser/BookmarkRepository.kt index 3bcac82..2f2bfb3 100644 --- a/app/src/main/java/com/tv/browser/BookmarkRepository.kt +++ b/app/src/main/java/com/tv/browser/BookmarkRepository.kt @@ -6,7 +6,7 @@ import org.json.JSONArray import org.json.JSONObject /** - * 使用 SharedPreferences 持久化收藏列表。 + * 使用内存缓存 + SharedPreferences 持久化收藏列表。 */ class BookmarkRepository(context: Context) { @@ -15,18 +15,25 @@ class BookmarkRepository(context: Context) { private val keyInitialized = "bookmarks_initialized" fun getAll(): List { - val isInitialized = prefs.getBoolean(keyInitialized, false) - val json = prefs.getString(keyBookmarks, null) - if (!isInitialized && json == null) { - save(DEFAULT_BOOKMARKS) - prefs.edit() - .putBoolean(keyInitialized, true) - .putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION) - .apply() - return DEFAULT_BOOKMARKS + cachedBookmarks?.let { return it } + synchronized(Companion) { + cachedBookmarks?.let { return it } + val isInitialized = prefs.getBoolean(keyInitialized, false) + val json = prefs.getString(keyBookmarks, null) + if (!isInitialized && json == null) { + save(DEFAULT_BOOKMARKS) + prefs.edit() + .putBoolean(keyInitialized, true) + .putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION) + .apply() + cachedBookmarks = DEFAULT_BOOKMARKS + return DEFAULT_BOOKMARKS + } + val list = parse(json) + val migrated = migrateDefaults(list) + cachedBookmarks = migrated + return migrated } - val list = parse(json) - return migrateDefaults(list) } private fun parse(json: String?): List { @@ -75,14 +82,18 @@ class BookmarkRepository(context: Context) { fun contains(url: String): Boolean = getAll().any { it.url == url } private fun save(list: List) { - val arr = JSONArray() - list.forEach { b -> - arr.put(JSONObject().apply { - put(KEY_URL, b.url) - put(KEY_TITLE, b.title) - }) + val snapshot = list.toList() + synchronized(Companion) { + cachedBookmarks = snapshot + val arr = JSONArray() + snapshot.forEach { b -> + arr.put(JSONObject().apply { + put(KEY_URL, b.url) + put(KEY_TITLE, b.title) + }) + } + prefs.edit().putString(keyBookmarks, arr.toString()).apply() } - prefs.edit().putString(keyBookmarks, arr.toString()).apply() } companion object { @@ -90,6 +101,8 @@ class BookmarkRepository(context: Context) { private const val KEY_URL = "url" private const val KEY_TITLE = "title" private const val KEY_DEFAULTS_VERSION = "bookmarks_defaults_version" + @Volatile + private var cachedBookmarks: List? = null /** 默认收藏版本:新增默认站点时 +1,并在 [DEFAULTS_ADDED_BY_VERSION] 登记 */ private const val DEFAULTS_VERSION = 4 diff --git a/app/src/main/java/com/tv/browser/BrowserActivity.kt b/app/src/main/java/com/tv/browser/BrowserActivity.kt index 8fab779..f15e644 100644 --- a/app/src/main/java/com/tv/browser/BrowserActivity.kt +++ b/app/src/main/java/com/tv/browser/BrowserActivity.kt @@ -4,51 +4,48 @@ import android.annotation.SuppressLint import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent +import android.content.res.Configuration import android.graphics.Bitmap -import android.graphics.Color -import android.graphics.drawable.GradientDrawable -import android.media.AudioManager -import android.os.Build +import android.net.Uri +import android.net.http.SslError import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.speech.RecognizerIntent +import android.util.Log import android.view.KeyEvent -import android.view.MotionEvent import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout -import android.widget.ImageView -import java.io.File -import android.util.Log -import android.util.TypedValue -import android.net.Uri -import android.net.http.SslError -import android.os.SystemClock +import android.view.WindowManager +import android.view.inputmethod.EditorInfo +import android.view.inputmethod.InputMethodManager import android.webkit.ConsoleMessage +import android.webkit.JavascriptInterface import android.webkit.SslErrorHandler import android.webkit.WebChromeClient +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebSettings -import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient -import android.view.inputmethod.InputMethodManager import android.widget.EditText +import android.widget.ImageView import android.widget.ProgressBar -import android.widget.TextView import android.widget.Toast -import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.FileProvider +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import androidx.webkit.ScriptHandler import com.tv.browser.databinding.ActivityBrowserBinding +import java.io.File +import java.util.Calendar /** * 视频嗅探条目(支持多线路检测与切换) @@ -64,23 +61,24 @@ data class SniffedVideo( ) /** - * Android TV 浏览器主界面:WebView + 工具栏,支持遥控器 D-pad 导航、手机网页遥控。 + * Android TV 浏览器主界面:WebView + 工具栏。 + * 模块化拆分后,模拟鼠标光标、网页视频全屏、视频嗅探状态机、全网搜片弹窗、手机遥控与界面弹窗分别由独立组件承载: + * - [CursorController]:模拟鼠标光标移动、边缘滚动、点击派发与显隐管理 + * - [CustomViewFullscreenHelper]:网页视频 CustomView 全屏与应用级全屏管理 + * - [VideoSnifferController]:视频嗅探列表、3 秒自动接管倒计时与内外部播放器调起 + * - [VodSearchDialog]:跨站聚合搜片输入与结果列表弹窗 + * - [RemoteControlHelper]:局域网手机遥控服务、输入法文字注入与快传文件处理 + * - [BrowserUiHelper]:TV/手机自适应响应式布局、缓存清理与设置弹窗 */ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { companion object { private const val TAG_VIDEO = "TVBrowser.Video" private const val REQUEST_CODE_VOICE_SEARCH = 1001 - private const val FILTER_MENU_TEXT = "🛡️ 不良动图过滤" - private const val VOD_SEARCH_MENU_TEXT = "🔎 全网搜片(在收藏的影视站里搜)" - /** 首页追剧列表最多显示几部 */ private const val CONTINUE_LIST_SIZE = 8 - /** 自动接管播放倒计时(秒) */ - private const val AUTO_PLAY_SECONDS = 3 - /** 手机端 UA,便于站点返回移动版布局 */ + internal const val MOBILE_UA = "Mozilla/5.0 (Linux; Android 13; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36" - /** 电脑桌面端 UA,提供完整网格排版 */ internal const val DESKTOP_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" internal const val PREFS_SETTINGS = "tv_browser_settings" @@ -88,7 +86,6 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { internal const val KEY_IS_DESKTOP_UA = "key_is_desktop_ua" internal val ZOOM_OPTIONS = intArrayOf(50, 75, 90, 100, 125, 150, 175, 200) - /** 返回键优先关闭的弹窗 tag 列表 */ private val DIALOG_FRAGMENT_TAGS = listOf("bookmarks", "user_scripts", "remote", "history", "settings") /** 当前浏览器使用的 UA(播放器 / 后台解析需与网页保持一致,部分站点的视频令牌与 UA 绑定) */ @@ -101,7 +98,13 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { internal var currentZoomLevel: Int = 100 internal var isDesktopUa: Boolean = false + internal var isMouseMode: Boolean = true private var isToolbarVisible: Boolean = true + private var isOnHomePage: Boolean = true + private var isEditingCards: Boolean = false + private var currentPageTitle: String = "" + private var hasDocStartHook: Boolean = false + private var desktopViewportScript: ScriptHandler? = null internal lateinit var binding: ActivityBrowserBinding private lateinit var webView: WebView @@ -112,96 +115,34 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { private lateinit var userScriptRepository: UserScriptRepository internal lateinit var playbackHistoryRepository: PlaybackHistoryRepository - /** 多视频线路嗅探列表 */ - private val sniffedVideoList = mutableListOf() - - /** 自动接管播放:已处理过(已自动播放或用户取消)的页面,回到该页不再重复倒计时 */ - private var autoPlayHandledPage: String? = null - private var autoPlayPage: String? = null - private var autoPlayRemaining = 0 - private val autoPlayHandler = android.os.Handler(android.os.Looper.getMainLooper()) - private val autoPlayTick = object : Runnable { - override fun run() { onAutoPlayTick() } - } - - /** WebView 发出的请求头记录(原生播放器回放 Referer / UA 等,解决 403) */ - private val requestHeaderCache = VideoSnifferHelper.RequestHeaderCache() - - /** 当前页面的站点适配信息(片名 / 选集 / 下一集),换页时清空 */ - private var currentSiteInfo: SiteInfo? = null - - /** 站点适配原始 JSON(净化播放页用),换页时清空 */ - private var currentSiteInfoJson: String? = null - - /** 系统 WebView 是否支持页面执行前注入(不支持时走 evaluateJavascript 兜底) */ - private var hasDocStartHook: Boolean = false - - /** 电脑版视口脚本句柄(页面执行前注入);null 表示手机版或系统 WebView 不支持,需在页面回调里兜底注入 */ - private var desktopViewportScript: ScriptHandler? = null + private lateinit var cursorController: CursorController + private lateinit var fullscreenHelper: CustomViewFullscreenHelper + private lateinit var snifferController: VideoSnifferController + private lateinit var remoteHelper: RemoteControlHelper + private var vodSearchHelper: VodSearchHelper? = null + private var dialogCursorLifecycleCallbacks: FragmentManager.FragmentLifecycleCallbacks? = null /** 当前主页面的主机名与是否执行强力过滤(shouldInterceptRequest 在后台线程读取) */ @Volatile private var currentPageHost: String? = null @Volatile private var contentFilterActive: Boolean = false - /** - * 已切到原生 / 外部播放器或退到后台、网页视频被暂停:期间拒绝网页的子资源请求(shouldInterceptRequest 在后台线程读取)。 - * 网页播放器暂停后仍会继续缓冲分片(部分站点还带 P2P 加速),会和原生播放器抢网速;回到本页或打开新页面时解除。 - */ + /** 已切到原生/外部播放器或退到后台时拒绝网页子资源请求;当前 Activity 是否处于前台可见状态 */ @Volatile private var webMediaSuspended: Boolean = false - /** 当前 Activity 是否处于前台可见状态(onResume ~ onPause),供后台网络拦截与页面完成回调判断是否立即暂停网页媒体 */ @Volatile private var isActivityResumed: Boolean = false - /** 防跳转劫持:用户最近一次真实点击的链接与时间(JS 线程写入,UI 线程读取) */ + /** 防跳转劫持:用户最近一次真实点击的链接与时间 */ @Volatile private var lastUserLink: String? = null @Volatile private var lastUserLinkTime: Long = 0L - /** WebView 全屏视频时系统传入的 CustomView 容器;未实现 onShowCustomView 时 WebView 会向页面报告不支持全屏 */ - private var fullscreenContainer: FrameLayout? = null - private var customViewCallback: WebChromeClient.CustomViewCallback? = null - - /** 应用级全屏:隐藏工具栏让 WebView 占满屏幕(JS 全屏不支持时的降级) */ - private var isAppFullscreen: Boolean = false - - /** 鼠标模式:true=遥控器 D-pad 移动光标、确认键点击;false=普通焦点导航(D-pad 在工具栏/地址栏/网页间移动焦点) */ - internal var isMouseMode: Boolean = true - - /** 卡片整理模式:true 时首页卡片显示 X 删除按钮 */ - private var isEditingCards: Boolean = false - - /** 模拟鼠标箭头:全屏可移动(含工具栏),遥控器 D-pad 移动、确认键点击 */ - private var cursorView: ImageView? = null - private var cursorOverlay: ViewGroup? = null - private var contentLayout: ViewGroup? = null - private var cursorX: Float = 0f - private var cursorY: Float = 0f - private var cursorStepPx: Int = 9 - private val edgeScrollPx: Int get() = (48 * resources.displayMetrics.density).toInt() private val mainHandler = Handler(Looper.getMainLooper()) - - private var currentPageTitle: String = "" - - /** 当前页面嗅探到的视频源地址与标题 */ - private var currentSniffedVideoUrl: String? = null - private var currentSniffedVideoTitle: String? = null - - /** 当前页是否检测到只能网页播放的加密流(央视网等),用于提示一次并替换"未嗅探到视频"的提示 */ - private var webOnlyNoticeShown: Boolean = false - - /** 遥控服务状态指示器:定时刷新小圆点颜色与首页状态栏 */ private val remoteStatusChecker = object : Runnable { override fun run() { - updateRemoteStatusDot() - updateHomeStatusBar() + remoteHelper.updateStatusDot(binding.remoteStatusDot) + binding.homeStatusBar.visibility = View.GONE mainHandler.postDelayed(this, 3000) } } - /** 当前是否在首页(收藏卡片),false 表示在浏览网页 */ - private var isOnHomePage: Boolean = true - - /** 手机遥控 HTTP 服务:常驻,仅在本 Activity 销毁时停止 */ - private var remoteControlServer: RemoteControlServer? = null - private val homeUrl: String get() = getString(R.string.default_home_url) @@ -211,8 +152,9 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { binding = ActivityBrowserBinding.inflate(layoutInflater) setContentView(binding.root) - contentLayout = binding.root.findViewById(R.id.contentLayout) - cursorOverlay = binding.root.findViewById(R.id.cursorOverlay) + val contentLayout = binding.root.findViewById(R.id.contentLayout) + val cursorOverlay = binding.root.findViewById(R.id.cursorOverlay) + val cursorView = binding.root.findViewById(R.id.cursorView) webView = binding.webView urlBar = binding.urlBar progressBar = binding.progressBar @@ -221,33 +163,32 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { userScriptRepository = UserScriptRepository(this) playbackHistoryRepository = PlaybackHistoryRepository(this) - // 开启硬件加速(WebView 全屏视频必须) - window.setFlags( - android.view.WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, - android.view.WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED + cursorController = CursorController( + this, cursorView, cursorOverlay, contentLayout, webView, + binding.toolbarScroll, binding.toolbar, binding.webContainer ) + fullscreenHelper = CustomViewFullscreenHelper( + this, webView, binding.toolbarScroll, cursorController, + { isOnHomePage }, { isMouseMode }, { js, cb -> runVideoJs(js, cb) } + ) + snifferController = VideoSnifferController( + this, webView, binding.btnNativePlay, binding.btnFloatingPlay, + { isOnHomePage }, { currentPageTitle }, { suspendWebMedia() } + ) + remoteHelper = RemoteControlHelper(this, mainHandler, this) - // 地址栏:允许调起电视系统输入法(遥控器确认键 / 光标点击时弹出,见 setupUrlBarKeys) - // 注意:不要设置 showSoftInputOnFocus = false,否则电视键盘永远无法弹出 + window.setFlags(WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED) + BrowserUiHelper.ensureWebViewCacheDirs(this) + BrowserUiHelper.cleanExpiredUploadFilesAsync(this) - // 预建 WebView 缓存目录,避免部分 TV 上 opendir/写索引失败导致崩溃或打不开页 - ensureWebViewCacheDirs() - - // 触屏遥控:每次打开应用就启动服务,关闭应用时在 onDestroy 停止 - ensureRemoteControlServerStarted() - // 初始化遥控状态指示器并启动定时刷新 - initRemoteStatusDot() + remoteHelper.ensureStarted() + remoteHelper.initStatusDot(binding.remoteStatusDot) mainHandler.post(remoteStatusChecker) - // 注册全屏 JS 桥接接口 webView.addJavascriptInterface(FullscreenBridge(), "AndroidFullscreenBridge") - // 注册视频嗅探 JS 桥接接口 webView.addJavascriptInterface(VideoBridge(), "AndroidVideoBridge") - // 防跳转劫持:页面脚本上报用户真实点击的链接 webView.addJavascriptInterface(NavGuardBridge(), "AndroidNavGuard") - // 净化播放页:页面内「全屏 / 加书签」按钮回调 webView.addJavascriptInterface(PurifyBridge(), PurifyHelper.BRIDGE_NAME) - // 页面执行前注入嗅探 Hook(所有 frame,含跨域 iframe);旧版 WebView 不支持时在 onPageStarted 兜底 hasDocStartHook = VideoSnifferHelper.installDocumentStartHook(webView) Log.d(TAG_VIDEO, "document-start hook installed=$hasDocStartHook") @@ -263,49 +204,44 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { builtInZoomControls = false useWideViewPort = true loadWithOverviewMode = true - // 启用标准缓存策略,加速网页重复加载与记住登录态 cacheMode = WebSettings.LOAD_DEFAULT minimumFontSize = (12 * currentZoomLevel / 100).coerceIn(1, 12) textZoom = currentZoomLevel - // 允许视频自动播放(TV 场景) mediaPlaybackRequiresUserGesture = false - // 混合内容:HTTPS 页允许加载 HTTP 资源(部分站点需要) mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE javaScriptCanOpenWindowsAutomatically = true setSupportMultipleWindows(false) - // 无图模式:彻底不加载网络图片(过滤不良动图的兜底方案) blockNetworkImage = ContentFilterHelper.isNoImageMode(this@BrowserActivity) } - // 浏览器标识:UA 字符串 + Client Hints;电脑版额外强制宽视口 UserAgentHelper.applyUserAgent(webView.settings, isDesktopUa) desktopViewportScript = UserAgentHelper.setDesktopViewport(webView, isDesktopUa, null) - // 监听网页上下滚动:向下滚动收起工具栏,向上滚动唤出工具栏 (webView as? RemoteWebView)?.onScrollChangeListener = { _, scrollY, _, oldScrollY -> val dy = scrollY - oldScrollY - if (dy > 20) { - setToolbarVisible(false) - } else if (dy < -20) { - setToolbarVisible(true) - } + if (dy > 20) setToolbarVisible(false) else if (dy < -20) setToolbarVisible(true) } + setupWebViewClients() + setupToolbarAndHomeGrid() + setupFocusAndUrlBarListeners() + + webView.post { + if (intent?.action == Intent.ACTION_VIEW && intent.data != null) loadUrl(intent.data!!.toString()) + else showHomePage() + } + } + + private fun setupWebViewClients() { webView.webViewClient = object : WebViewClient() { - override fun shouldOverrideUrlLoading( - view: WebView?, - request: WebResourceRequest? - ): Boolean { + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { val targetUrl = request?.url?.toString() ?: return false if (AdBlockHelper.isHijackScheme(targetUrl)) { Log.d("TVBrowser.AdBlock", "Blocked hijack scheme: $targetUrl") return true } - // iframe 内的跳转(如影视站的播放器 iframe)交给 WebView 自己处理,不能拉到主页面打开 if (request.isForMainFrame == false) return false - // 防跳转劫持:跨站跳转必须是用户刚点击的链接(重定向链放行) if (contentFilterActive && !request.isRedirect && isHijackNavigation(targetUrl)) { val domain = ContentFilterHelper.registrableDomain(request.url?.host) ?: targetUrl - Log.d("TVBrowser.Filter", "Blocked hijack navigation: $targetUrl") Toast.makeText(this@BrowserActivity, "已拦截可疑跳转:$domain\n(误拦可在 更多→不良动图过滤 中对本站关闭)", Toast.LENGTH_SHORT).show() return true } @@ -313,40 +249,20 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { return true } - override fun shouldInterceptRequest( - view: WebView?, - request: WebResourceRequest? - ): WebResourceResponse? { + override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { val reqUrl = request?.url?.toString() ?: return null - // 原生 / 外部播放器播放中或已退到后台:网页不再加载任何子资源(分片、清单刷新、P2P 回源、广告轮询),把网速留给播放器或避免后台偷跑 if (request.isForMainFrame) { if (isActivityResumed) webMediaSuspended = false } else if (webMediaSuspended || !isActivityResumed) { return VideoSnifferHelper.createRejectedResponse() } - // 0. 记录请求头,原生播放器回放时使用(Referer / UA 等) - requestHeaderCache.put(reqUrl, request.requestHeaders) + snifferController.requestHeaderCache.put(reqUrl, request.requestHeaders) if (request.isForMainFrame) updateContentFilterState(reqUrl) - // 1. 去广告请求拦截 - if (AdBlockHelper.isEnabled(this@BrowserActivity) && AdBlockHelper.shouldBlockUrl(reqUrl)) { - return AdBlockHelper.createEmptyResponse() - } - // 1.1 强力过滤:第三方 GIF 动图、广告路径图片直接不下载 - if (contentFilterActive && ContentFilterHelper.shouldBlockImage(request, currentPageHost)) { - Log.d("TVBrowser.Filter", "Blocked image: $reqUrl") - return ContentFilterHelper.createEmptyImageResponse() - } - // 1.2 强力过滤:非标准端口的第三方脚本(广告联盟 / 点击劫持,常阻塞整页加载) - if (contentFilterActive && ContentFilterHelper.shouldBlockScript(request, currentPageHost)) { - Log.d("TVBrowser.Filter", "Blocked script: $reqUrl") - return ContentFilterHelper.createEmptyScriptResponse() - } - // 1.3 兼容旧版 WebView:拦截并替换含高版本语法(如 ES2021 `??=`)而导致播放器崩溃的插件脚本 + if (AdBlockHelper.isEnabled(this@BrowserActivity) && AdBlockHelper.shouldBlockUrl(reqUrl)) return AdBlockHelper.createEmptyResponse() + if (contentFilterActive && ContentFilterHelper.shouldBlockImage(request, currentPageHost)) return ContentFilterHelper.createEmptyImageResponse() + if (contentFilterActive && ContentFilterHelper.shouldBlockScript(request, currentPageHost)) return ContentFilterHelper.createEmptyScriptResponse() VideoSnifferHelper.createCompatScriptResponse(reqUrl)?.let { return it } - // 2. 视频流嗅探拦截(.m3u8, .mp4 等) - if (VideoSnifferHelper.isVideoStreamUrl(reqUrl)) { - onVideoSniffed(reqUrl, currentPageTitle, "net") - } + if (VideoSnifferHelper.isVideoStreamUrl(reqUrl)) onVideoSniffed(reqUrl, currentPageTitle, "net") return super.shouldInterceptRequest(view, request) } @@ -354,28 +270,13 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { super.onPageStarted(view, url, favicon) progressBar.visibility = View.VISIBLE if (!isOnHomePage && url != "about:blank") urlBar.setText(url ?: "") - currentPageTitle = "" // 清空上一个页面的旧标题,防止竞态错乱 + currentPageTitle = "" hideKeyboard() - // 重置当前页视频嗅探状态 - cancelAutoPlay(markHandled = false) - currentSniffedVideoUrl = null - currentSniffedVideoTitle = null - sniffedVideoList.clear() - webOnlyNoticeShown = false - currentSiteInfo = null - currentSiteInfoJson = null - binding.btnNativePlay.visibility = View.GONE - binding.btnFloatingPlay.visibility = View.GONE - // 旧版 WebView 不支持页面执行前注入:尽早在主页面注入嗅探 Hook + snifferController.resetForNewPage() if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null) injectDesktopModeFallback(view) - // 尽早注入全屏增强脚本(补齐 iframe 的 allowfullscreen 属性并 hook 全屏 API) view?.evaluateJavascript(VideoControlHelper.JS_ENABLE_FULLSCREEN_ENHANCEMENT, null) - // 注入广告拦截与防劫持脚本 - if (AdBlockHelper.isEnabled(this@BrowserActivity)) { - view?.evaluateJavascript(AdBlockHelper.JS_INJECT_ADBLOCK, null) - } - // 强力过滤:隐藏可疑动图 / 色情博彩广告 + if (AdBlockHelper.isEnabled(this@BrowserActivity)) view?.evaluateJavascript(AdBlockHelper.JS_INJECT_ADBLOCK, null) updateContentFilterState(url) if (contentFilterActive) view?.evaluateJavascript(ContentFilterHelper.JS_CONTENT_FILTER, null) if (!isActivityResumed) { @@ -386,10 +287,8 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { override fun onPageCommitVisible(view: WebView?, url: String?) { super.onPageCommitVisible(view, url) - // 新文档 window 刚提交时立即注入嗅探 Hook(onPageStarted 时新 window 可能尚未提交) if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null) injectDesktopModeFallback(view) - // 新页面首帧可见时再注入一次,尽量在广告图显示前隐藏 if (contentFilterActive) view?.evaluateJavascript(ContentFilterHelper.JS_CONTENT_FILTER, null) if (!isActivityResumed) { suspendWebMedia() @@ -404,13 +303,9 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null) injectDesktopModeFallback(view) view?.evaluateJavascript(VideoControlHelper.JS_ENABLE_FULLSCREEN_ENHANCEMENT, null) - if (AdBlockHelper.isEnabled(this@BrowserActivity)) { - view?.evaluateJavascript(AdBlockHelper.JS_INJECT_ADBLOCK, null) - } + if (AdBlockHelper.isEnabled(this@BrowserActivity)) view?.evaluateJavascript(AdBlockHelper.JS_INJECT_ADBLOCK, null) if (contentFilterActive) view?.evaluateJavascript(ContentFilterHelper.JS_CONTENT_FILTER, null) - // 注入 DOM 视频嗅探脚本 view?.evaluateJavascript(VideoSnifferHelper.JS_SNIFF_VIDEO, null) - // 站点适配:提取片名 / 选集 / 下一集(目前支持苹果 CMS 通用模板) view?.evaluateJavascript(VideoSnifferHelper.JS_SITE_ADAPTER_MACCMS, null) url?.let { injectUserScripts(it) } url?.let { historyRepository.add(it, currentPageTitle.ifEmpty { it }) } @@ -421,21 +316,12 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { } @Deprecated("Deprecated in Java") - override fun onReceivedError( - view: WebView?, - errorCode: Int, - description: String?, - failingUrl: String? - ) { + override fun onReceivedError(view: WebView?, errorCode: Int, description: String?, failingUrl: String?) { progressBar.visibility = View.GONE Toast.makeText(this@BrowserActivity, getString(R.string.error_page), Toast.LENGTH_SHORT).show() } - override fun onReceivedError( - view: WebView?, - request: WebResourceRequest?, - error: android.webkit.WebResourceError? - ) { + override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { super.onReceivedError(view, request, error) if (request?.isForMainFrame == true) { progressBar.visibility = View.GONE @@ -443,23 +329,13 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { } } - override fun onReceivedSslError( - view: WebView?, - handler: SslErrorHandler?, - error: SslError? - ) { + override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { val primary = error?.primaryError - val reason = when (primary) { - SslError.SSL_DATE_INVALID -> "证书日期无效/电视系统时间未同步 (net_error -201)" - SslError.SSL_EXPIRED -> "证书已过期" - SslError.SSL_IDMISMATCH -> "证书域名不匹配" - SslError.SSL_NOTYETVALID -> "证书尚未生效" - SslError.SSL_UNTRUSTED -> "证书颁发机构不受信任" - else -> "SSL证书异常(代码: $primary)" - } - Log.w(TAG_VIDEO, "onReceivedSslError: $reason for url=${error?.url}") - // 电视端核心兼容:放行 SSL 证书错误,确保网页及视频源正常加载,杜绝 net_error -201 导致白屏/握手失败 - handler?.proceed() + val isDateError = primary == SslError.SSL_DATE_INVALID || primary == SslError.SSL_EXPIRED || + primary == SslError.SSL_NOTYETVALID || Calendar.getInstance().get(Calendar.YEAR) < 2025 + val allow = AppSettings.isLenientSsl(this@BrowserActivity) || isDateError + Log.w(TAG_VIDEO, "onReceivedSslError: code=$primary allow=$allow url=${error?.url}") + if (allow) handler?.proceed() else handler?.cancel() } } @@ -473,56 +349,42 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { override fun onReceivedTitle(view: WebView?, title: String?) { currentPageTitle = title?.trim().orEmpty() val currentUrl = view?.url - if (!currentUrl.isNullOrBlank() && currentPageTitle.isNotEmpty()) { - historyRepository.add(currentUrl, currentPageTitle) - } + if (!currentUrl.isNullOrBlank() && currentPageTitle.isNotEmpty()) historyRepository.add(currentUrl, currentPageTitle) } override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { - consoleMessage?.let { - Log.d(TAG_VIDEO, "JS console [${it.messageLevel()}] ${it.sourceId()}:${it.lineNumber()} ${it.message()}") - } + consoleMessage?.let { Log.d(TAG_VIDEO, "JS console [${it.messageLevel()}] ${it.sourceId()}:${it.lineNumber()} ${it.message()}") } return false } - /** 实现此回调后,WebView 才会向页面报告支持全屏,视频/页面的 requestFullscreen 才会生效 */ - override fun onShowCustomView(view: View?, callback: WebChromeClient.CustomViewCallback?) { - handleShowCustomView(view, callback) - } + override fun onShowCustomView(view: View?, callback: CustomViewCallback?) = fullscreenHelper.showCustomView(view, callback) @Deprecated("Deprecated in Java") - override fun onShowCustomView( - view: View?, - requestedOrientation: Int, - callback: WebChromeClient.CustomViewCallback? - ) { - handleShowCustomView(view, callback) - } + override fun onShowCustomView(view: View?, requestedOrientation: Int, callback: CustomViewCallback?) = + fullscreenHelper.showCustomView(view, callback) - override fun onHideCustomView() { - exitCustomViewFullscreen() - } + override fun onHideCustomView() = fullscreenHelper.exitCustomView() } + } + private fun setupToolbarAndHomeGrid() { binding.btnMouseMode.setOnClickListener { toggleCardEditing() } binding.btnRemote.setOnClickListener { showRemoteControlDialog() } binding.btnRefresh.setOnClickListener { webView.reload() } - // 收藏:加到首页卡片,方便下次直接进入 binding.btnBookmark.setOnClickListener { addCurrentToBookmarks() } - binding.btnNativePlay.setOnClickListener { launchNativePlayer() } - binding.btnFloatingPlay.setOnClickListener { launchNativePlayer() } - // 收藏夹按钮改为“清除缓存” + binding.btnNativePlay.setOnClickListener { snifferController.launchNativePlayer() } + binding.btnFloatingPlay.setOnClickListener { snifferController.launchNativePlayer() } binding.btnMore.setOnClickListener { showMoreMenu() } binding.btnHistory.setOnClickListener { showHistoryDialog() } binding.btnHome.setOnClickListener { showHomePage() } binding.btnGo.setOnClickListener { goFromUrlBar() } binding.btnVoiceSearch.setOnClickListener { startVoiceSearch() } - // 首页:收藏夹卡片网格(5列排版,尺寸精致紧凑) - binding.homeGrid.layoutManager = GridLayoutManager(this, calculateHomeSpanCount()) - isMouseMode = !isCompactScreen() - applyResponsiveLayout() - binding.homeGrid.itemAnimator = null // 关闭默认变更动画,避免刷新时焦点闪烁/丢失 - val homeAdapter = HomeCardAdapter( + + binding.homeGrid.layoutManager = GridLayoutManager(this, BrowserUiHelper.calculateHomeSpanCount(this)) + isMouseMode = !BrowserUiHelper.isCompactScreen(this) + BrowserUiHelper.applyResponsiveLayout(this, binding) + binding.homeGrid.itemAnimator = null + binding.homeGrid.adapter = HomeCardAdapter( bookmarkRepository.getAll(), onItemClick = { bookmark -> loadUrl(bookmark.url) }, onDelete = { bookmark -> @@ -530,7 +392,6 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { bookmarkRepository.remove(bookmark.url) val newList = bookmarkRepository.getAll() (binding.homeGrid.adapter as? HomeCardAdapter)?.updateList(newList) - // 删除后把焦点放回相邻卡片,避免焦点丢失需要重新找 if (newList.isNotEmpty()) { val target = oldIndex.coerceIn(0, newList.size - 1) binding.homeGrid.post { @@ -542,205 +403,54 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { } } ) - binding.homeGrid.adapter = homeAdapter + } - cursorView = binding.root.findViewById(R.id.cursorView) - cursorView?.isFocusable = false - cursorView?.isClickable = false - // 触摸穿透:光标层和光标视图的触摸都转发到下方 contentLayout(工具栏 + WebView) - val content = contentLayout - fun forwardTouchToContent(event: MotionEvent): Boolean { - if (content == null) return false - val loc = IntArray(2) - content.getLocationOnScreen(loc) - val lx = event.rawX - loc[0] - val ly = event.rawY - loc[1] - val f = MotionEvent.obtain(event.downTime, event.eventTime, event.action, lx, ly, event.metaState) - // FrameLayout(clickable=false) 默认会将触摸事件直接穿透给下层 contentLayout,无需手动重复 dispatchTouchEvent(否则会导致 ACTION_DOWN 触发两次,打断横向滑动) - f.recycle() - return false - } - cursorView?.setOnTouchListener { _, event -> forwardTouchToContent(event) } - cursorOverlay?.setOnTouchListener { _, event -> forwardTouchToContent(event) } - cursorOverlay?.post { initCursorPosition() } - // 不监听 overlay 的 layout 变化,避免底部左右移动时 webView.scrollBy 触发布局导致 clampCursorToBounds 错误改动 cursorY;全屏切换等时由调用方主动 clamp - - // 仅当焦点切到网页非输入区域时收起地址栏残留的软键盘;若用户直接点击网页内的输入框(onCheckIsTextEditor=true),不能收起键盘 + private fun setupFocusAndUrlBarListeners() { webView.setOnFocusChangeListener { _, hasFocus -> if (hasFocus && !webView.onCheckIsTextEditor()) hideKeyboard() } - // 弹窗(DialogFragment)显示时隐藏光标、销毁时恢复,避免弹窗遮挡光标(按弹窗对象去重,重复回调也不会累积) dialogCursorLifecycleCallbacks = object : FragmentManager.FragmentLifecycleCallbacks() { - override fun onFragmentStarted(fm: FragmentManager, f: androidx.fragment.app.Fragment) { - if (f is androidx.fragment.app.DialogFragment) setCursorOverlayVisible(false, f) + override fun onFragmentStarted(fm: FragmentManager, f: Fragment) { + if (f is DialogFragment) setCursorOverlayVisible(false, f) } - override fun onFragmentViewDestroyed(fm: FragmentManager, f: androidx.fragment.app.Fragment) { - if (f is androidx.fragment.app.DialogFragment) setCursorOverlayVisible(true, f) + override fun onFragmentViewDestroyed(fm: FragmentManager, f: Fragment) { + if (f is DialogFragment) setCursorOverlayVisible(true, f) } - override fun onFragmentDestroyed(fm: FragmentManager, f: androidx.fragment.app.Fragment) { - if (f is androidx.fragment.app.DialogFragment) setCursorOverlayVisible(true, f) + override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) { + if (f is DialogFragment) setCursorOverlayVisible(true, f) } } supportFragmentManager.registerFragmentLifecycleCallbacks(dialogCursorLifecycleCallbacks!!, false) urlBar.setOnEditorActionListener { _, actionId, event -> - val isGo = actionId == android.view.inputmethod.EditorInfo.IME_ACTION_GO || - actionId == android.view.inputmethod.EditorInfo.IME_ACTION_DONE || - actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH || - (actionId == android.view.inputmethod.EditorInfo.IME_NULL && - event?.action == KeyEvent.ACTION_DOWN) - if (isGo) { - goFromUrlBar() - true - } else false - } - setupUrlBarKeys() - - // 首屏:有外部链接则打开网页,否则显示首页(收藏卡片,不打开网址) - webView.post { - when { - intent?.action == Intent.ACTION_VIEW && intent.data != null -> { - loadUrl(intent.data!!.toString()) - } - else -> showHomePage() - } + val isGo = actionId == EditorInfo.IME_ACTION_GO || actionId == EditorInfo.IME_ACTION_DONE || + actionId == EditorInfo.IME_ACTION_SEARCH || (actionId == EditorInfo.IME_NULL && event?.action == KeyEvent.ACTION_DOWN) + if (isGo) { goFromUrlBar(); true } else false } + BrowserUiHelper.setupUrlBarKeys(this, urlBar) { goFromUrlBar() } } override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) setIntent(intent) - if (intent?.action == Intent.ACTION_VIEW && intent.data != null && ::webView.isInitialized) { - loadUrl(intent.data!!.toString()) - } + if (intent?.action == Intent.ACTION_VIEW && intent.data != null && ::webView.isInitialized) loadUrl(intent.data!!.toString()) } - /** 是否为 Android TV 设备 */ - private fun isTelevisionDevice(): Boolean { - val uiModeManager = getSystemService(Context.UI_MODE_SERVICE) as? android.app.UiModeManager - return uiModeManager?.currentModeType == android.content.res.Configuration.UI_MODE_TYPE_TELEVISION - } - - /** 是否为窄屏设备(如手机,非电视且屏幕宽度 < 600dp) */ - private fun isCompactScreen(): Boolean { - if (isTelevisionDevice()) return false - return resources.configuration.screenWidthDp < 600 - } - - /** 根据屏幕宽度与设备类型计算首页收藏卡片网格列数(TV 大屏固定 5 列不变) */ - private fun calculateHomeSpanCount(): Int { - if (isTelevisionDevice()) return 5 - val widthDp = resources.configuration.screenWidthDp - return when { - widthDp >= 900 -> 5 - widthDp >= 700 -> 4 - widthDp >= 480 -> 3 - else -> 2 - } - } - - private fun dp(value: Int): Int = - TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt() - - /** - * 自适应屏幕布局: - * - TV 大屏保持原有 5 列网格与大尺寸工具栏按钮,零破坏; - * - 手机等窄屏自动缩减卡片列数(竖屏 2 列、横屏 3~4 列)与工具栏按钮内边距,配合工具栏横向滑动,彻底解决拥挤与显示不全问题。 - */ - private fun applyResponsiveLayout() { - val compact = isCompactScreen() - (binding.homeGrid.layoutManager as? GridLayoutManager)?.spanCount = calculateHomeSpanCount() - - if (compact) { - binding.homeGrid.setPadding(dp(12), dp(8), dp(12), dp(12)) - binding.continueHeader.setPadding(dp(14), 0, dp(14), 0) - binding.continueList.setPadding(dp(12), dp(1), dp(12), dp(2)) - binding.continueHint.text = "(点击续播 · 长按移除)" - binding.toolbar.setPadding(dp(12), dp(8), dp(12), dp(8)) - } else { - binding.homeGrid.setPadding(dp(20), dp(4), dp(20), dp(10)) - binding.continueHeader.setPadding(dp(24), 0, dp(24), 0) - binding.continueList.setPadding(dp(20), dp(1), dp(20), dp(2)) - binding.continueHint.text = "(确定键续播 · 长按移除)" - binding.toolbar.setPadding(dp(24), dp(12), dp(24), dp(12)) - } - - val btnHeight = if (compact) dp(40) else dp(48) - val btnMinWidth = if (compact) dp(60) else dp(80) - val btnTextSp = if (compact) 13f else 14f - val btnPadH = if (compact) dp(10) else dp(12) - - val normalButtons = listOf( - binding.btnMouseMode, - binding.btnHome, - binding.btnRefresh, - binding.btnBookmark, - binding.btnRemote, - binding.btnHistory, - binding.btnMore, - binding.btnGo - ) - normalButtons.forEach { btn -> - btn.layoutParams = btn.layoutParams.apply { height = btnHeight } - btn.minHeight = btnHeight - btn.minWidth = btnMinWidth - btn.setPadding(btnPadH, 0, btnPadH, 0) - btn.setTextSize(TypedValue.COMPLEX_UNIT_SP, btnTextSp) - } - - binding.btnNativePlay.let { btn -> - btn.layoutParams = btn.layoutParams.apply { height = btnHeight } - btn.minHeight = btnHeight - btn.minWidth = if (compact) dp(76) else dp(96) - btn.setPadding(btnPadH, 0, btnPadH, 0) - btn.setTextSize(TypedValue.COMPLEX_UNIT_SP, btnTextSp) - } - - binding.btnVoiceSearch.let { btn -> - btn.layoutParams = btn.layoutParams.apply { height = btnHeight } - btn.minHeight = btnHeight - btn.minWidth = if (compact) dp(60) else dp(76) - btn.setPadding(btnPadH, 0, btnPadH, 0) - btn.setTextSize(TypedValue.COMPLEX_UNIT_SP, btnTextSp) - } - - binding.remoteBtnContainer.layoutParams = binding.remoteBtnContainer.layoutParams.apply { - height = btnHeight - } - - (binding.urlBar.layoutParams as? android.widget.LinearLayout.LayoutParams)?.let { lp -> - lp.width = if (compact) dp(160) else dp(180) - lp.height = btnHeight - lp.marginStart = if (compact) dp(8) else dp(16) - lp.marginEnd = if (compact) dp(6) else dp(8) - binding.urlBar.layoutParams = lp - } - val urlPadH = if (compact) dp(12) else dp(16) - binding.urlBar.setPadding(urlPadH, 0, urlPadH, 0) - binding.urlBar.setTextSize(TypedValue.COMPLEX_UNIT_SP, if (compact) 14f else 18f) - } - - override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) if (::binding.isInitialized) { - applyResponsiveLayout() - cursorOverlay?.post { clampCursorToBounds() } + BrowserUiHelper.applyResponsiveLayout(this, binding) + binding.toolbarScroll.post { cursorController.clampToBounds() } } } - /** 显示首页:收藏夹卡片网格,不加载网址;并关闭所有 WebView */ private fun showHomePage() { - cancelAutoPlay(markHandled = false) + snifferController.clearForHomePage() closeAllWebViews() isOnHomePage = true - currentSniffedVideoUrl = null - currentSniffedVideoTitle = null - sniffedVideoList.clear() - binding.btnNativePlay.visibility = View.GONE - binding.btnFloatingPlay.visibility = View.GONE setToolbarVisible(true) - updateHomeStatusBar() + binding.homeStatusBar.visibility = View.GONE exitCardEditing() updateToolbarButtons() updateCursorOverlayVisibility() @@ -753,45 +463,11 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { updateContinueWatchingBanner() } - /** 刷新首页追剧列表(继续观看):最近未看完的剧,一部剧一张卡片 */ - private fun updateContinueWatchingBanner() { - val list = binding.continueList - val adapter = list.adapter as? ContinueWatchingAdapter ?: ContinueWatchingAdapter( - onPlay = { record -> PlayerActivity.start(this, record.url, record.title) }, - onRemove = { record, pos -> - playbackHistoryRepository.remove(record.url) - Toast.makeText(this, "已从继续观看移除:${record.displayTitle()}", Toast.LENGTH_SHORT).show() - updateContinueWatchingBanner() - val count = list.adapter?.itemCount ?: 0 - if (count > 0) { - val target = pos.coerceIn(0, count - 1) - list.post { list.findViewHolderForAdapterPosition(target)?.itemView?.requestFocus() } - } else { - binding.btnMouseMode.requestFocus() - } - } - ).also { - list.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false) - list.itemAnimator = null - list.adapter = it - } - val records = playbackHistoryRepository.getRecent(CONTINUE_LIST_SIZE) - adapter.submit(records) - binding.layoutContinueWatching.visibility = if (records.isNotEmpty()) View.VISIBLE else View.GONE - } + private fun updateContinueWatchingBanner() = + BrowserUiHelper.updateContinueWatchingBanner(this, binding, playbackHistoryRepository, CONTINUE_LIST_SIZE) - /** 首页追剧列表第一张卡片获取焦点 */ - private fun focusContinueList(): Boolean { - val list = binding.continueList - val first = list.findViewHolderForAdapterPosition(0)?.itemView ?: list.getChildAt(0) - if (first != null) return first.requestFocus() - if ((list.adapter?.itemCount ?: 0) == 0) return false - list.scrollToPosition(0) - list.post { list.findViewHolderForAdapterPosition(0)?.itemView?.requestFocus() } - return true - } + private fun focusContinueList(): Boolean = BrowserUiHelper.focusContinueList(binding) - /** 关闭所有 WebView:停止加载、加载空白页、清空历史,释放当前页面 */ private fun closeAllWebViews() { try { webView.stopLoading() @@ -800,7 +476,6 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { } catch (_: Exception) { } } - /** 显示网页:隐藏首页,显示 WebView */ private fun showWebPage() { isOnHomePage = false binding.homeStatusBar.visibility = View.GONE @@ -814,44 +489,12 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { urlBar.setNextFocusDownId(binding.webView.id) } - /** 清理 WebView 相关缓存与存储 */ - internal fun clearBrowserCache(showToast: Boolean = true) { - try { - webView.clearCache(true) - webView.clearHistory() - } catch (_: Exception) { } - try { - WebStorage.getInstance().deleteAllData() - } catch (_: Exception) { } - try { - val cacheDir = cacheDir - File(cacheDir, "WebView").deleteRecursively() - } catch (_: Exception) { } - if (showToast) { - Toast.makeText(this, getString(R.string.cache_cleared), Toast.LENGTH_SHORT).show() - } - } + internal fun clearBrowserCache(showToast: Boolean = true) = BrowserUiHelper.clearBrowserCache(this, webView, showToast) - private fun ensureWebViewCacheDirs() { - try { - val cacheDir = cacheDir ?: return - val base = File(cacheDir, "WebView/Default") - base.mkdirs() - File(base, "HTTP Cache/Code Cache/js").mkdirs() - } catch (_: Exception) { } - } - - /** 油猴式:在页面加载完成后注入匹配的用户脚本 */ private fun injectUserScripts(currentUrl: String) { - val scripts = userScriptRepository.getEnabledForUrl(currentUrl) - if (scripts.isEmpty()) return - scripts.forEach { script -> - try { - val js = script.script.trim() - if (js.isNotEmpty()) { - webView.evaluateJavascript(js) { _ -> } - } - } catch (_: Exception) { } + userScriptRepository.getEnabledForUrl(currentUrl).forEach { script -> + val js = script.script.trim() + if (js.isNotEmpty()) runCatching { webView.evaluateJavascript(js, null) } } } @@ -862,14 +505,9 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { showVodSearch(input) return } - val url = when { - input.isEmpty() -> homeUrl - else -> SearchEngineHelper.resolveUrl(this, input) - } - loadUrl(url) + loadUrl(if (input.isEmpty()) homeUrl else SearchEngineHelper.resolveUrl(this, input)) } - /** 电视遥控器语音输入搜索 (P4) */ private fun startVoiceSearch() { try { val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { @@ -878,7 +516,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { } @Suppress("DEPRECATION") startActivityForResult(intent, REQUEST_CODE_VOICE_SEARCH) - } catch (e: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { Toast.makeText(this, "当前电视设备未安装语音识别服务,可使用手机遥控打字", Toast.LENGTH_LONG).show() } } @@ -887,98 +525,23 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == REQUEST_CODE_VOICE_SEARCH && resultCode == RESULT_OK) { - val matches = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) - val voiceText = matches?.firstOrNull()?.trim().orEmpty() + val voiceText = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull()?.trim().orEmpty() if (voiceText.isNotBlank()) { urlBar.setText(voiceText) - if (!SearchEngineHelper.isLikelyUrl(voiceText) && SearchEngineHelper.isVodDefault(this)) { - showVodSearch(voiceText) - return - } - val targetUrl = SearchEngineHelper.resolveUrl(this, voiceText) - loadUrl(targetUrl) + if (!SearchEngineHelper.isLikelyUrl(voiceText) && SearchEngineHelper.isVodDefault(this)) showVodSearch(voiceText) + else loadUrl(SearchEngineHelper.resolveUrl(this, voiceText)) } } } - /** 收起软键盘 */ - private fun hideKeyboard() { - val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager ?: return - val token = currentFocus?.windowToken ?: window.decorView.windowToken - imm.hideSoftInputFromWindow(token, 0) - } - - /** 弹出电视系统输入法编辑地址栏 */ - private fun showUrlBarKeyboard() { - if (!urlBar.hasFocus()) urlBar.requestFocus() - // 全选现有内容,输入即替换(TV 上逐字删除很麻烦) - urlBar.selectAll() - val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager ?: return - // flags=0 表示用户显式请求;部分电视 ROM 会忽略 SHOW_IMPLICIT - val shown = imm.showSoftInput(urlBar, 0) - if (!shown) { - // 个别老电视 ROM 的输入法尚未绑定到该 View,稍后重试一次 - urlBar.postDelayed({ - if (urlBar.hasFocus()) imm.showSoftInput(urlBar, 0) - }, 120) - } - } - - /** 输入法当前是否可见(API 30+ 精确,低版本为近似判断) */ - private fun isImeVisible(): Boolean { - val insets = androidx.core.view.ViewCompat.getRootWindowInsets(urlBar) ?: return false - return insets.isVisible(androidx.core.view.WindowInsetsCompat.Type.ime()) - } - - /** - * 地址栏遥控器按键: - * - 遥控器确认键(DPAD_CENTER / ENTER):弹出电视键盘(原来是直接跳转网页,导致键盘永远调不起来) - * - 输入法发来的回车:执行前往 - * - 遥控器左右键:键盘未弹出时直接移动焦点,不再先在文字里逐字移动光标(首页左右切换不灵敏的原因之一) - */ - private fun setupUrlBarKeys() { - urlBar.setOnKeyListener { _, keyCode, event -> - if (event == null) return@setOnKeyListener false - // 输入法发出的按键一般带 FLAG_SOFT_KEYBOARD;不用 deviceId 判断,因为 adb / 手机遥控注入的按键 deviceId 也是虚拟键盘 - val softKey = (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD) != 0 - when (keyCode) { - KeyEvent.KEYCODE_DPAD_CENTER -> { - // 遥控器确认键:抬起时弹出电视键盘 - if (event.action == KeyEvent.ACTION_UP) showUrlBarKeyboard() - true - } - KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { - if (softKey || isImeVisible()) { - // 输入法上的“前往/回车” - if (event.action == KeyEvent.ACTION_DOWN) goFromUrlBar() - } else if (event.action == KeyEvent.ACTION_UP) { - // 部分遥控器的确认键发送的是 ENTER:同样弹出键盘 - showUrlBarKeyboard() - } - true - } - KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> { - if (softKey || isImeVisible()) return@setOnKeyListener false - if (event.action == KeyEvent.ACTION_DOWN) { - val dir = if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) View.FOCUS_LEFT else View.FOCUS_RIGHT - urlBar.focusSearch(dir)?.requestFocus(dir) - } - true - } - else -> false - } - } - } + private fun hideKeyboard() = BrowserUiHelper.hideKeyboard(this) private fun loadUrl(url: String) { showWebPage() webView.loadUrl(url) } - /** 供遥控弹窗等调用:在浏览器主 WebView 中打开指定 URL(如 127.0.0.1:8766 自测) */ - fun loadUrlInBrowser(url: String) { - loadUrl(url) - } + fun loadUrlInBrowser(url: String) = loadUrl(url) private fun addCurrentToBookmarks() { val url = webView.url?.trim().orEmpty().ifEmpty { urlBar.text.toString().trim() } @@ -986,767 +549,140 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { Toast.makeText(this, getString(R.string.error_page), Toast.LENGTH_SHORT).show() return } - val title = currentPageTitle.ifEmpty { url } - bookmarkRepository.add(Bookmark(url = url, title = title)) + bookmarkRepository.add(Bookmark(url = url, title = currentPageTitle.ifEmpty { url })) Toast.makeText(this, getString(R.string.bookmark_added), Toast.LENGTH_SHORT).show() } - private fun showBookmarksDialog() { - val dialog = BookmarksDialog().apply { - onOpenUrl = { url -> loadUrl(url) } - } - dialog.show(supportFragmentManager, "bookmarks") - // 光标隐藏/恢复由 FragmentLifecycleCallbacks 统一处理(onFragmentStarted / onFragmentViewDestroyed) - } + private fun showHistoryDialog() = + HistoryDialog().apply { onOpenUrl = { url -> loadUrl(url) } }.show(supportFragmentManager, "history") - private fun showHistoryDialog() { - HistoryDialog().apply { - onOpenUrl = { url -> loadUrl(url) } - }.show(supportFragmentManager, "history") - } - - internal fun showUserScriptsDialog() { - UserScriptsDialog().show(supportFragmentManager, "user_scripts") - } + internal fun showUserScriptsDialog() = UserScriptsDialog().show(supportFragmentManager, "user_scripts") private fun runVideoJs(js: String, onResult: (String) -> Unit = {}) { webView.evaluateJavascript(js) { result -> - runOnUiThread { onResult(unwrapJsString(result)) } + val unwrapped = result?.trim()?.removeSurrounding("\"")?.replace("\\\"", "\"").orEmpty() + runOnUiThread { onResult(unwrapped) } } } - /** 触发页面内视频全屏:先 requestFullscreen(标准路径→onShowCustomView),再尝试双击;均未弹出则 1s 后降级为应用级全屏 */ - private fun triggerVideoFullscreen() { - Log.d(TAG_VIDEO, "triggerVideoFullscreen() called (page video → onShowCustomView)") - runVideoJs(VideoControlHelper.JS_FULLSCREEN) { r -> - runOnUiThread { - Log.d(TAG_VIDEO, "JS_FULLSCREEN result=$r") - if (r != "ok") { - runVideoJs(VideoControlHelper.JS_FULLSCREEN_DBLCLICK) { r2 -> - runOnUiThread { Log.d(TAG_VIDEO, "JS_FULLSCREEN_DBLCLICK result=$r2") } - } - } - } - } - // 若 onShowCustomView 未在 1s 内触发,再降级为应用级全屏 - mainHandler.postDelayed({ - if (fullscreenContainer == null && !isAppFullscreen) { - Log.d(TAG_VIDEO, "fallback: page video fullscreen did not show, toggle app fullscreen") - toggleAppFullscreen() - } - }, 1000) - } - - private fun unwrapJsString(result: String?): String { - if (result == null) return "" - return result.trim().removeSurrounding("\"").replace("\\\"", "\"") - } - - /** 初始化模拟鼠标箭头位置为全屏中心(含工具栏区域) */ - private fun initCursorPosition() { - val overlay = cursorOverlay ?: return - val cursor = cursorView ?: return - cursorStepPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 9f, resources.displayMetrics).toInt() - val cw = overlay.width - val ch = overlay.height - val cwv = cursor.width - val chv = cursor.height - if (cw <= 0 || ch <= 0) return - cursorX = ((cw - cwv) / 2f).coerceIn(0f, (cw - cwv).toFloat()) - cursorY = ((ch - chv) / 2f).coerceIn(0f, (ch - chv).toFloat()) - cursor.x = cursorX - cursor.y = cursorY - } - - /** 按步长移动光标并刷新位置;仅当有垂直移动(dy!=0)时触发边缘滚动,避免底部纯左右移动时 scrollBy 导致光标看起来上下动 */ - private fun moveCursor(dx: Int, dy: Int) { - val overlay = cursorOverlay ?: return - val cursor = cursorView ?: return - val ow = overlay.width - val oh = overlay.height - val cw = cursor.width - val ch = cursor.height - val maxX = (ow - cw).toFloat().coerceAtLeast(0f) - val maxY = (oh - ch).toFloat().coerceAtLeast(0f) - cursorX = (cursorX + dx).coerceIn(0f, maxX) - cursorY = (cursorY + dy).coerceIn(0f, maxY) - cursor.x = cursorX - cursor.y = cursorY - if (dy != 0) { - performEdgeScrollIfNeeded(dy) - if (!isOnHomePage && cursorY < 64 * resources.displayMetrics.density) { - setToolbarVisible(true) - } - } - } - - /** 光标在页面区域顶部/底部边缘时滚动 WebView(必须方向匹配:向上移动且在顶边才向上滚,向下移动且在底边才向下滚,避免底部往上移动时误触向下滚) */ - private fun performEdgeScrollIfNeeded(dy: Int) { - if (isOnHomePage) return - val toolbar = binding.toolbarScroll - val webContainer = binding.webContainer - val th = if (toolbar.visibility == View.VISIBLE) toolbar.height else 0 - val wh = webContainer.height - if (wh <= 0) return - val webTop = th.toFloat() - val webBottom = th + wh - val dir = when { - dy < 0 && cursorY < webTop + edgeScrollPx -> -1 - dy > 0 && cursorY > webBottom - edgeScrollPx -> 1 - else -> return - } - if (webView.canScrollVertically(dir)) { - webView.scrollBy(0, dir * edgeScrollPx) - } else { - // 页面本身已到头(或页面不滚动,只有内部滚动区):滚动光标下方最近的可滚动区域,等同鼠标滚轮 - scrollInnerAtCursor(dir) - } - } - - /** 滚动光标位置处最近的可滚动元素(选集列表等带独立滚动条的区域);跨域 iframe 内部无法处理 */ - private fun scrollInnerAtCursor(dir: Int) { - val overlay = cursorOverlay ?: return - val ww = webView.width - val wh = webView.height - if (ww <= 0 || wh <= 0) return - val wl = IntArray(2).also { webView.getLocationOnScreen(it) } - val ol = IntArray(2).also { overlay.getLocationOnScreen(it) } - val fx = ((cursorX + ol[0] - wl[0]) / ww).coerceIn(0f, 0.999f) - val fy = ((cursorY + ol[1] - wl[1]) / wh).coerceIn(0f, 0.999f) - val fdy = dir * edgeScrollPx.toFloat() / wh - val js = String.format( - java.util.Locale.US, - "(function(fx,fy,fdy){try{var d=fdy*innerHeight,n=document.elementFromPoint(fx*innerWidth,fy*innerHeight);" + - "while(n&&n.nodeType===1&&n!==document.body&&n!==document.documentElement){" + - "var s=getComputedStyle(n);if(/(auto|scroll|overlay)/.test(s.overflowY)&&n.scrollHeight>n.clientHeight+1){" + - "var b=n.scrollTop;n.scrollTop=b+d;if(n.scrollTop!==b)return;}n=n.parentElement;}}catch(e){}})(%.4f,%.4f,%.4f);", - fx, fy, fdy - ) - webView.evaluateJavascript(js, null) - } - - /** - * 光标隐藏原因集合(弹窗、网页全屏视频等)。 - * 用「来源对象」做键而不是计数:同一来源多次隐藏 / 恢复都是幂等的, - * 避免 onStart 重复回调或某处忘记恢复导致计数永远 > 0、光标再也不显示(只能重启电视)。 - */ - private val cursorHideTokens = LinkedHashSet() - private var dialogCursorLifecycleCallbacks: FragmentManager.FragmentLifecycleCallbacks? = null - /** 网页视频全屏(CustomView)隐藏光标所用的来源标记 */ - private val customViewCursorToken = Any() - internal fun updateCursorOverlayVisibility() { - // 自愈:清理已经不在界面上的弹窗来源,防止任何遗漏导致光标永久隐藏 - cursorHideTokens.removeAll { it is androidx.fragment.app.Fragment && !it.isAdded } - if (fullscreenContainer == null) cursorHideTokens.remove(customViewCursorToken) - val shouldShow = !isOnHomePage && isMouseMode && cursorHideTokens.isEmpty() - cursorOverlay?.visibility = if (shouldShow) View.VISIBLE else View.GONE + if (!::cursorController.isInitialized) return + val isCustomView = ::fullscreenHelper.isInitialized && fullscreenHelper.fullscreenContainer != null + cursorController.updateVisibility(isOnHomePage, isMouseMode, isCustomView) } - /** 按来源隐藏 / 恢复光标;[token] 一般传弹窗自身 */ fun setCursorOverlayVisible(visible: Boolean, token: Any) { - if (visible) cursorHideTokens.remove(token) else cursorHideTokens.add(token) - updateCursorOverlayVisibility() + if (!::cursorController.isInitialized) return + val isCustomView = ::fullscreenHelper.isInitialized && fullscreenHelper.fullscreenContainer != null + cursorController.setVisible(visible, token, isOnHomePage, isMouseMode, isCustomView) } - /** 根据当前页面(首页/网页)切换工具栏按钮的显示 */ private fun updateToolbarButtons() { val onHome = isOnHomePage - // 仅首页显示 binding.btnMouseMode.visibility = if (onHome) View.VISIBLE else View.GONE - // 仅网页显示 binding.btnHome.visibility = if (onHome) View.GONE else View.VISIBLE binding.btnRefresh.visibility = if (onHome) View.GONE else View.VISIBLE binding.btnBookmark.visibility = if (onHome) View.GONE else View.VISIBLE - val hasVideo = !onHome && !currentSniffedVideoUrl.isNullOrBlank() + val hasVideo = !onHome && ::snifferController.isInitialized && !snifferController.currentSniffedVideoUrl.isNullOrBlank() binding.btnNativePlay.visibility = if (hasVideo) View.VISIBLE else View.GONE binding.btnFloatingPlay.visibility = if (hasVideo) View.VISIBLE else View.GONE } - /** - * 视频嗅探回调:网络拦截、页面 Hook 或 DOM 检测到视频源(支持多线路检测与切换)。 - * 可能在 WebView 后台线程回调,统一切到主线程处理。 - * @param kind 发现方式;kind == "hls" 表示页面 Hook 确认是 m3u8(即使地址没有 .m3u8 后缀) - * @param frameUrl 发现视频的 frame 地址(iframe 播放器页) - */ - private fun onVideoSniffed(url: String, title: String, kind: String = "", frameUrl: String? = null) { - runOnUiThread { handleVideoSniffed(url, title, kind, frameUrl) } - } + private fun onVideoSniffed(url: String, title: String, kind: String = "", frameUrl: String? = null) = + runOnUiThread { snifferController.handleVideoSniffed(url, title, kind, frameUrl) } - private fun handleVideoSniffed(url: String, title: String, kind: String, frameUrl: String?) { - if (isOnHomePage) return - val trimmed = url.trim() - if (trimmed.isEmpty()) return - // 央视网等加密流:原生播放会花屏,不接管,留在网页里播放 - if (VideoSnifferHelper.isWebOnlyStream(trimmed)) { - Log.d(TAG_VIDEO, "Web-only stream, skip native: $trimmed") - if (!webOnlyNoticeShown) { - webOnlyNoticeShown = true - Toast.makeText(this, VideoSnifferHelper.WEB_ONLY_MESSAGE, Toast.LENGTH_LONG).show() - } - return - } - val isHls = VideoSnifferHelper.isHls(trimmed, kind) - if (!isHls && !VideoSnifferHelper.isVideoStreamUrl(trimmed)) return - if (AdBlockHelper.isEnabled(this) && AdBlockHelper.shouldBlockUrl(trimmed)) return - // 去重:同一地址,或是已有主清单的码率子清单 - if (sniffedVideoList.any { it.url == trimmed || VideoSnifferHelper.isLikelyVariantOf(trimmed, it.url) }) return + internal fun cancelAutoPlay(markHandled: Boolean): Boolean = + if (::snifferController.isInitialized) snifferController.cancelAutoPlay(markHandled) else false - val effectiveTitle = title.ifBlank { currentPageTitle.ifBlank { "在线影视" } } - val streamType = when { - isHls -> "HLS 极速流" - trimmed.contains(".mp4", ignoreCase = true) -> "MP4 标清/高清" - else -> "通用视频流" - } - sniffedVideoList.add(SniffedVideo(trimmed, effectiveTitle, streamType, kind, frameUrl)) - currentSniffedVideoUrl = trimmed - currentSniffedVideoTitle = effectiveTitle - Log.d(TAG_VIDEO, "Video sniffed[$kind]: $trimmed (total streams=${sniffedVideoList.size})") - - binding.btnNativePlay.visibility = View.VISIBLE - binding.btnFloatingPlay.visibility = View.VISIBLE - updatePlayButtonsText() - if (currentSiteInfo == null) { - webView.evaluateJavascript(VideoSnifferHelper.JS_SITE_ADAPTER_MACCMS, null) - } - if (isHls) maybeStartAutoPlay() - } - - private fun updatePlayButtonsText() { - if (autoPlayRemaining > 0) { - binding.btnFloatingPlay.text = "🎬 ${autoPlayRemaining} 秒后全屏播放 · 按返回取消" - } else if (sniffedVideoList.size > 1) { - binding.btnFloatingPlay.text = "🎬 检测到 ${sniffedVideoList.size} 条视频线路 · 点此选线播放" - } else { - binding.btnFloatingPlay.text = "🎬 检测到视频源 · 点此原生播放" - } - binding.btnNativePlay.text = if (sniffedVideoList.size > 1) "🎬 线路(${sniffedVideoList.size})" else "🎬 原生播放" - } - - // --------------------------------------------------------------------------------------- - // 自动接管播放:嗅探到 HLS 正片后倒计时自动进入原生全屏播放器,按返回取消 - // --------------------------------------------------------------------------------------- - - private fun currentPageKey(): String? = - webView.url?.takeIf { it.startsWith("http") }?.let { SiteInfo.normalizeUrl(it) } - - private fun maybeStartAutoPlay() { - if (!AppSettings.isAutoTakeover(this) || isOnHomePage || autoPlayRemaining > 0) return - if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) return - val page = currentPageKey() ?: return - if (page == autoPlayHandledPage) return - autoPlayPage = page - autoPlayRemaining = AUTO_PLAY_SECONDS - Log.d(TAG_VIDEO, "auto play countdown start: $page") - updatePlayButtonsText() - autoPlayHandler.removeCallbacks(autoPlayTick) - autoPlayHandler.postDelayed(autoPlayTick, 1000) - } - - private fun onAutoPlayTick() { - if (autoPlayRemaining <= 0) return - val page = currentPageKey() - if (isOnHomePage || page == null || page != autoPlayPage) { - cancelAutoPlay(markHandled = false) - return - } - if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) { - cancelAutoPlay(markHandled = true) - return - } - autoPlayRemaining-- - if (autoPlayRemaining > 0) { - updatePlayButtonsText() - autoPlayHandler.postDelayed(autoPlayTick, 1000) - return - } - autoPlayHandledPage = page - updatePlayButtonsText() - // HLS 优先(mp4 常是贴片广告) - val video = sniffedVideoList.firstOrNull { VideoSnifferHelper.isHls(it.url, it.kind) } ?: sniffedVideoList.firstOrNull() - if (video != null) { - Log.d(TAG_VIDEO, "auto play: ${video.url}") - startNativePlayer(video) - } - } - - /** 取消自动播放倒计时;[markHandled] 为 true 时本页不再自动播放 */ - internal fun cancelAutoPlay(markHandled: Boolean): Boolean { - if (autoPlayRemaining <= 0) return false - autoPlayHandler.removeCallbacks(autoPlayTick) - autoPlayRemaining = 0 - if (markHandled) autoPlayHandledPage = autoPlayPage - autoPlayPage = null - updatePlayButtonsText() - return true - } - - /** 把嗅探结果组装成可播放的视频源:带上来源页与 WebView 实际发出的请求头 */ - private fun buildVideoSource(video: SniffedVideo): VideoSource { - val pageUrl = webView.url?.takeIf { it.startsWith("http") } - val headers = VideoSnifferHelper.buildPlaybackHeaders( - url = video.url, - captured = requestHeaderCache.get(video.url), - userAgent = webView.settings.userAgentString ?: currentUserAgent(this), - fallbackReferer = video.frameUrl ?: pageUrl - ) - val info = currentSiteInfo - val title = info?.displayTitle()?.takeIf { it.isNotBlank() } ?: video.title - return VideoSource( - url = video.url, - title = title, - pageUrl = pageUrl, - headers = headers, - isHls = VideoSnifferHelper.isHls(video.url, video.kind) - ) - } - - /** 当前页的选集信息(只有和当前播放页匹配时才使用) */ - private fun siteInfoForCurrentPage(): SiteInfo? { - val info = currentSiteInfo ?: return null - val page = webView.url ?: return null - return if (SiteInfo.normalizeUrl(info.current) == SiteInfo.normalizeUrl(page)) info else null - } - - /** 暂停网页内所有音视频(含 iframe),避免切到原生播放器后网页仍在后台出声 */ - private fun pauseWebMedia() { - try { - webView.evaluateJavascript(VideoSnifferHelper.JS_PAUSE_ALL_MEDIA, null) - } catch (_: Exception) { } - } - - /** - * 切到原生 / 外部播放器前调用:暂停网页视频、停止 hls.js 缓冲,并在回到本页前拒绝网页的子资源请求, - * 避免后台网页和播放器抢网速。回到本页时由 [resumeWebMediaIfSuspended] 恢复。 - */ private fun suspendWebMedia() { - pauseWebMedia() + runCatching { webView.evaluateJavascript(VideoSnifferHelper.JS_PAUSE_ALL_MEDIA, null) } webMediaSuspended = true } - /** 回到本页:解除请求拦截,恢复网页 hls.js 缓冲(不自动播放) */ private fun resumeWebMediaIfSuspended() { if (!webMediaSuspended) return webMediaSuspended = false - try { - webView.evaluateJavascript(VideoSnifferHelper.JS_RESUME_MEDIA_LOADING, null) - } catch (_: Exception) { } + runCatching { webView.evaluateJavascript(VideoSnifferHelper.JS_RESUME_MEDIA_LOADING, null) } } - /** - * 防跳转劫持:跨站的主页面跳转,只有和用户 3 秒内真实点击的链接(或其主域)一致才放行。 - * 典型劫持:页面监听全局点击 / 定时器,把整页跳到色情、博彩站。 - */ - private fun isHijackNavigation(targetUrl: String): Boolean { - val targetDomain = ContentFilterHelper.registrableDomain(runCatching { Uri.parse(targetUrl).host }.getOrNull()) - val pageDomain = ContentFilterHelper.registrableDomain(currentPageHost) - if (targetDomain == null || pageDomain == null || targetDomain == pageDomain) return false - val link = lastUserLink - if (!link.isNullOrBlank() && SystemClock.uptimeMillis() - lastUserLinkTime < 3000) { - if (link == targetUrl) return false - val linkDomain = ContentFilterHelper.registrableDomain(runCatching { Uri.parse(link).host }.getOrNull()) - if (linkDomain == targetDomain) return false - } - return true - } + private fun isHijackNavigation(targetUrl: String): Boolean = + BrowserUiHelper.isHijackNavigation(targetUrl, currentPageHost, lastUserLink, lastUserLinkTime) - /** 根据页面地址更新"强力过滤"状态:全局开关 + 站点白名单(可在 WebView 后台线程调用) */ private fun updateContentFilterState(pageUrl: String?) { - val host = pageUrl?.takeIf { it.startsWith("http", ignoreCase = true) } - ?.let { runCatching { Uri.parse(it).host }.getOrNull() } + val host = pageUrl?.takeIf { it.startsWith("http", ignoreCase = true) }?.let { runCatching { Uri.parse(it).host }.getOrNull() } currentPageHost = host contentFilterActive = host != null && ContentFilterHelper.shouldFilter(this, host) } - private fun startNativePlayer(video: SniffedVideo) { - cancelAutoPlay(markHandled = true) - currentPageKey()?.let { autoPlayHandledPage = it } - suspendWebMedia() - val source = buildVideoSource(video) - Log.d(TAG_VIDEO, "launch native: ${source.url} page=${source.pageUrl} headers=${source.headers.keys}") - PlayerActivity.start(this, source, siteInfoForCurrentPage()) - } - - /** 没有可原生播放的视频时的提示:检测到加密流时说明原因 */ - private fun noVideoMessage(): String = - if (webOnlyNoticeShown) VideoSnifferHelper.WEB_ONLY_MESSAGE else "暂未嗅探到可播放的视频源" - - /** 启动原生全屏播放器:多线路时弹出选择对话框 */ - private fun launchNativePlayer() { - if (sniffedVideoList.isEmpty()) { - val url = currentSniffedVideoUrl - if (url.isNullOrBlank()) { - Toast.makeText(this, noVideoMessage(), Toast.LENGTH_SHORT).show() - return - } - startNativePlayer(SniffedVideo(url, currentSniffedVideoTitle ?: currentPageTitle, "")) - return - } - // HLS 优先(mp4 常是贴片广告) - val hlsList = sniffedVideoList.filter { VideoSnifferHelper.isHls(it.url, it.kind) } - if (sniffedVideoList.size == 1 || hlsList.size == 1) { - startNativePlayer(hlsList.firstOrNull() ?: sniffedVideoList[0]) - return - } - val items = sniffedVideoList.mapIndexed { idx, s -> - "线路 ${idx + 1} [${s.type}]" - }.toTypedArray() - AlertDialog.Builder(this) - .setTitle("选择播放线路 (${sniffedVideoList.size} 条可用)") - .setItems(items) { _, which -> - startNativePlayer(sniffedVideoList[which]) - } - .setNegativeButton("取消", null) - .show() - } - - /** 调用系统外部已安装的专业播放器(Kodi / MX Player / VLC 等) */ - private fun launchExternalPlayer() { - if (sniffedVideoList.isEmpty()) { - val url = currentSniffedVideoUrl - if (url.isNullOrBlank()) { - Toast.makeText(this, noVideoMessage(), Toast.LENGTH_SHORT).show() - return - } - startExternalPlayerIntent(SniffedVideo(url, currentSniffedVideoTitle ?: currentPageTitle, "")) - return - } - if (sniffedVideoList.size == 1) { - startExternalPlayerIntent(sniffedVideoList[0]) - return - } - val items = sniffedVideoList.mapIndexed { idx, s -> - "线路 ${idx + 1} [${s.type}]" - }.toTypedArray() - AlertDialog.Builder(this) - .setTitle("选择调用外部播放器的线路") - .setItems(items) { _, which -> - startExternalPlayerIntent(sniffedVideoList[which]) - } - .setNegativeButton("取消", null) - .show() - } - - private fun startExternalPlayerIntent(video: SniffedVideo) { - val source = buildVideoSource(video) - try { - suspendWebMedia() - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(Uri.parse(source.url), if (source.isHls) "application/x-mpegURL" else "video/*") - putExtra("title", source.title) - // MX Player 等支持的请求头格式:[key1, value1, key2, value2, ...] - if (source.headers.isNotEmpty()) { - putExtra("headers", source.headers.flatMap { listOf(it.key, it.value) }.toTypedArray()) - } - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - startActivity(Intent.createChooser(intent, "选择外部播放器")) - } catch (e: Exception) { - Log.e(TAG_VIDEO, "External player launch failed", e) - Toast.makeText(this, "未找到外部播放器,切换内置原生播放", Toast.LENGTH_SHORT).show() - PlayerActivity.start(this, source, siteInfoForCurrentPage()) - } - } - - /** 显示系统设置中心对话框 (大屏两栏设计) */ - fun showSettingsDialog(initialTab: Int = SettingsDialog.TAB_PLAYBACK) { + fun showSettingsDialog(initialTab: Int = SettingsDialog.TAB_PLAYBACK) = SettingsDialog.newInstance(initialTab).show(supportFragmentManager, "settings") - } - /** - * "更多"菜单:当前页面相关操作 + 少量常用快捷开关;完整设置在「系统设置中心」。 - * 快捷开关与设置中心调用同一份实现。菜单项为(文字, 动作)对,按位置分发,不依赖文字匹配。 - */ - private fun showMoreMenu() { - fun onOff(on: Boolean) = if (on) "开启" else "关闭" - val items = mutableListOf Unit>>() - items.add("⚙️ 系统设置中心(播放 / 浏览 / 安全 / 存储)" to { showSettingsDialog() }) - if (sniffedVideoList.isNotEmpty() || !currentSniffedVideoUrl.isNullOrBlank()) { - val lineCount = sniffedVideoList.size.coerceAtLeast(1) - items.add("🎬 原生播放器全屏播放(${lineCount} 条线路)" to { launchNativePlayer() }) - items.add("📺 调用外部播放器(MX / Kodi / VLC)" to { launchExternalPlayer() }) - } - items.add(VOD_SEARCH_MENU_TEXT to { showVodSearchInput() }) - items.add("⏱️ 播放历史记录" to { showPlaybackHistoryDialog() }) - // 常用快捷开关 - items.add("自动全屏播放(当前:${onOff(AppSettings.isAutoTakeover(this))})" to { toggleAutoTakeover() }) - items.add("净化播放页(当前:${onOff(AppSettings.isPurifyPage(this))})" to { togglePurifyPage() }) - items.add("浏览器标识(当前:${if (isDesktopUa) "电脑桌面版" else "移动手机版"})" to { toggleUserAgent() }) - items.add("清除缓存" to { clearBrowserCache(showToast = true) }) - items.add("关于" to { showSettingsDialog(SettingsDialog.TAB_ABOUT) }) + private fun showMoreMenu() = BrowserUiHelper.showMoreMenu( + this, snifferController.sniffedVideoList.size, snifferController.hasAnyVideo, + onVodSearch = { showVodSearchInput() }, + onPlaybackHistory = { BrowserUiHelper.showPlaybackHistoryDialog(this, playbackHistoryRepository, binding.layoutContinueWatching) }, + onLaunchNative = { snifferController.launchNativePlayer() }, + onLaunchExternal = { snifferController.launchExternalPlayer() } + ) - AlertDialog.Builder(this) - .setTitle(getString(R.string.more)) - .setItems(items.map { it.first }.toTypedArray()) { _, which -> items[which].second() } - .setNegativeButton(getString(R.string.close), null) - .show() - } - - // --------------------------------------------------------------------------------------- - // 设置项(系统设置中心调用;每项只有这一份实现) - // --------------------------------------------------------------------------------------- - - /** 自动全屏接管开关 */ internal fun toggleAutoTakeover() { val enable = !AppSettings.isAutoTakeover(this) AppSettings.setAutoTakeover(this, enable) if (!enable) cancelAutoPlay(markHandled = true) - Toast.makeText( - this, - if (enable) "已开启:检测到视频后 3 秒自动全屏播放" else "已关闭自动全屏播放", - Toast.LENGTH_SHORT - ).show() + val msg = if (enable) "已开启:检测到视频后 3 秒自动全屏播放" else "已关闭自动全屏播放" + Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() } - /** 广告拦截开关 */ internal fun toggleAdBlock() { val enable = !AdBlockHelper.isEnabled(this) AdBlockHelper.setEnabled(this, enable) Toast.makeText(this, if (enable) "已开启广告拦截" else "已关闭广告拦截", Toast.LENGTH_SHORT).show() } - /** 鼠标模式开关 */ internal fun toggleMouseMode() { isMouseMode = !isMouseMode updateCursorOverlayVisibility() - Toast.makeText( - this, - if (isMouseMode) "已开启鼠标模式" else "已关闭鼠标模式,改用按键导航", - Toast.LENGTH_SHORT - ).show() + val msg = if (isMouseMode) "已开启鼠标模式" else "已关闭鼠标模式,改用按键导航" + Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() } - /** 不良动图过滤设置:强力过滤 / 本站不过滤(白名单)/ 无图模式 */ internal fun showContentFilterDialog() { - val strong = ContentFilterHelper.isStrongFilterEnabled(this) - val noImage = ContentFilterHelper.isNoImageMode(this) - val host = if (isOnHomePage) null else webView.url - ?.takeIf { it.startsWith("http", ignoreCase = true) } - ?.let { runCatching { Uri.parse(it).host }.getOrNull() } - val siteWhitelisted = host != null && ContentFilterHelper.isWhitelisted(this, host) - - val labels = mutableListOf() - val actions = mutableListOf<() -> Unit>() - labels.add("强力过滤可疑动图(当前:${if (strong) "开启" else "关闭"})") - actions.add { - ContentFilterHelper.setStrongFilterEnabled(this, !strong) - Toast.makeText(this, if (!strong) "已开启强力过滤" else "已关闭强力过滤", Toast.LENGTH_SHORT).show() - } - if (host != null && strong) { - val domain = ContentFilterHelper.registrableDomain(host) ?: host - labels.add(if (siteWhitelisted) "本站 $domain(当前:不过滤)· 点此恢复过滤" else "本站 $domain(当前:过滤中)· 误伤点此不过滤") - actions.add { - ContentFilterHelper.setWhitelisted(this, host, !siteWhitelisted) - Toast.makeText(this, if (siteWhitelisted) "已恢复过滤:$domain" else "本站不再过滤:$domain", Toast.LENGTH_SHORT).show() + BrowserUiHelper.showContentFilterDialog(this, webView, isOnHomePage) { + if (!isOnHomePage) { + updateContentFilterState(webView.url) + webView.reload() } } - labels.add("无图模式(当前:${if (noImage) "开启" else "关闭"})· 不加载任何网页图片") - actions.add { - ContentFilterHelper.setNoImageMode(this, !noImage) - webView.settings.blockNetworkImage = !noImage - Toast.makeText(this, if (!noImage) "已开启无图模式" else "已关闭无图模式", Toast.LENGTH_SHORT).show() - } - - AlertDialog.Builder(this) - .setTitle(FILTER_MENU_TEXT) - .setItems(labels.toTypedArray()) { _, which -> - actions[which]() - // 立即对当前页生效 - if (!isOnHomePage) { - updateContentFilterState(webView.url) - webView.reload() - } - } - .setNegativeButton(getString(R.string.close), null) - .show() } - // --------------------------------------------------------------------------------------- - // 跨站聚合搜片(在收藏的苹果 CMS 影视站里同时搜索) - // --------------------------------------------------------------------------------------- - - private var vodSearchHelper: VodSearchHelper? = null - - /** 输入片名(地址栏里已有关键词时预填) */ private fun showVodSearchInput() { val prefill = urlBar.text.toString().trim() .takeIf { it.isNotEmpty() && !SearchEngineHelper.isLikelyUrl(it) }.orEmpty() - val input = EditText(this).apply { - setText(prefill) - setSelection(text.length) - hint = "输入片名,如:沧元图" - isSingleLine = true - imeOptions = android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH - } - val pad = (20 * resources.displayMetrics.density).toInt() - val box = FrameLayout(this).apply { - setPadding(pad, pad / 2, pad, 0) - addView(input) - } - val dialog = AlertDialog.Builder(this) - .setTitle("全网搜片(在收藏的影视站里搜)") - .setView(box) - .setPositiveButton("搜索") { _, _ -> - input.text.toString().trim().takeIf { it.isNotEmpty() }?.let { showVodSearch(it) } - } - .setNegativeButton(getString(R.string.close), null) - .create() - input.setOnEditorActionListener { _, _, _ -> - val kw = input.text.toString().trim() - if (kw.isNotEmpty()) { - dialog.dismiss() - showVodSearch(kw) - } - true - } - dialog.show() - input.requestFocus() + VodSearchDialog.showInputDialog(this, prefill) { kw -> showVodSearch(kw) } } - /** 搜索并展示结果:各站点结果陆续加入,点击打开详情页 */ private fun showVodSearch(keyword: String) { hideKeyboard() val helper = vodSearchHelper ?: VodSearchHelper(this).also { vodSearchHelper = it } - val sites = helper.collectSites(bookmarkRepository.getAll()) - val webEngine = SearchEngineHelper.getWebEngine(this) - if (sites.isEmpty()) { - Toast.makeText(this, "收藏里还没有影视站,先用${webEngine.name}搜索", Toast.LENGTH_SHORT).show() - loadUrl(SearchEngineHelper.buildSearchUrl(webEngine, keyword)) - return - } - - val results = mutableListOf() - val labels = mutableListOf() - val webLabel = "🌐 用${webEngine.name}搜索「$keyword」" - val pad = (20 * resources.displayMetrics.density).toInt() - val status = TextView(this).apply { - text = "正在 ${sites.size} 个收藏站点里搜索「$keyword」…" - textSize = 15f - setPadding(pad, pad / 2, pad, pad / 2) - } - val adapter = android.widget.ArrayAdapter(this, android.R.layout.simple_list_item_1, labels) - val list = android.widget.ListView(this).apply { this.adapter = adapter } - val root = android.widget.LinearLayout(this).apply { - orientation = android.widget.LinearLayout.VERTICAL - addView(status) - addView( - list, - android.widget.LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - (resources.displayMetrics.heightPixels * 0.55f).toInt() - ) - ) - } - fun refresh() { - labels.clear() - results.forEach { labels.add("${it.name}  · ${it.siteName}") } - labels.add(webLabel) - adapter.notifyDataSetChanged() - } - refresh() - - val dialog = AlertDialog.Builder(this) - .setTitle("搜片:$keyword") - .setView(root) - .setNegativeButton(getString(R.string.close), null) - .create() - list.setOnItemClickListener { _, _, position, _ -> - dialog.dismiss() - val r = results.getOrNull(position) - loadUrl(r?.detailUrl ?: SearchEngineHelper.buildSearchUrl(webEngine, keyword)) - } - dialog.setOnDismissListener { helper.cancel() } - dialog.show() - list.requestFocus() - - helper.search(keyword, sites, currentUserAgent(this), object : VodSearchHelper.Listener { - override fun onSiteDone(site: String, results0: List, done: Int, total: Int) { - if (!dialog.isShowing) return - if (results0.isNotEmpty()) { - // 稳定排序:同分时保持站点返回顺序 - val merged = (results + results0).sortedBy { VodSearchHelper.score(it.name, keyword) } - results.clear() - results.addAll(merged) - refresh() - } - val siteHits = results.map { it.host }.distinct().size - status.text = when { - done < total -> "正在搜索…($done/$total 个站点完成)已找到 ${results.size} 条" - results.isEmpty() -> "收藏的 $total 个站点里没找到「$keyword」(仅支持苹果CMS类影视站),可用下方网页搜索" - else -> "搜索完成:$siteHits 个站点共 ${results.size} 条,选一个打开详情页" - } - } - }) - } - - /** 默认搜索引擎选择([onChanged] 供设置中心刷新状态文字) */ - internal fun showSearchEngineDialog(onChanged: (() -> Unit)? = null) { - val engines = SearchEngineHelper.ENGINES - val currentId = SearchEngineHelper.getDefaultEngine(this).id - val names = engines.map { if (it.id == currentId) "● ${it.name} (当前默认)" else " ${it.name}" }.toTypedArray() - AlertDialog.Builder(this) - .setTitle("选择默认搜索引擎") - .setItems(names) { _, which -> - val chosen = engines[which] - SearchEngineHelper.setDefaultEngine(this, chosen.id) - onChanged?.invoke() - Toast.makeText(this, "默认搜索引擎已切换为:${chosen.name}", Toast.LENGTH_SHORT).show() - } - .setNegativeButton("取消", null) - .show() - } - - /** 播放历史记录与断点续播列表对话框 (P3) */ - private fun showPlaybackHistoryDialog() { - val records = playbackHistoryRepository.getAll() - if (records.isEmpty()) { - Toast.makeText(this, "暂无播放历史记录", Toast.LENGTH_SHORT).show() - return - } - val items = records.map { - "${it.title}\n进度:${it.formatPosition()} / ${it.formatDuration()} (${it.getProgressPercent()}%)" - }.toTypedArray() - AlertDialog.Builder(this) - .setTitle("播放历史记录 (${records.size})") - .setItems(items) { _, which -> - val r = records[which] - PlayerActivity.start(this, r.url, r.title) - } - .setPositiveButton("清空历史") { _, _ -> - playbackHistoryRepository.clearAll() - binding.layoutContinueWatching.visibility = View.GONE - Toast.makeText(this, "播放历史已清空", Toast.LENGTH_SHORT).show() - } - .setNegativeButton("关闭", null) - .show() - } - - /** 调节网页/文字缩放比例(50%~200%)([onChanged] 供设置中心刷新状态文字) */ - internal fun showZoomDialog(onChanged: (() -> Unit)? = null) { - val labels = arrayOf( - "50%(极小 · 宽屏全览)", - "75%(较小 · 适合桌面版)", - "90%(适度缩小)", - "100%(标准默认)", - "125%(放大 · 推荐)", - "150%(大字清晰)", - "175%(超大模式)", - "200%(特大视界)" + VodSearchDialog.showResultsDialog( + context = this, + keyword = keyword, + helper = helper, + bookmarks = bookmarkRepository.getAll(), + userAgent = currentUserAgent(this), + onOpenUrl = { url -> loadUrl(url) } ) - val currentIdx = ZOOM_OPTIONS.indexOf(currentZoomLevel).coerceAtLeast(0) - AlertDialog.Builder(this) - .setTitle("网页缩放比例") - .setSingleChoiceItems(labels, currentIdx) { dialog, which -> - val newZoom = ZOOM_OPTIONS[which] - applyZoom(newZoom) - getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE) - .edit().putInt(KEY_ZOOM_LEVEL, newZoom).apply() - onChanged?.invoke() - Toast.makeText(this, "已设置网页缩放为 ${newZoom}%", Toast.LENGTH_SHORT).show() - dialog.dismiss() - } - .setNegativeButton(getString(R.string.close), null) - .show() + } + + internal fun showSearchEngineDialog(onChanged: (() -> Unit)? = null) = + BrowserUiHelper.showSearchEngineDialog(this, onChanged) + + internal fun showZoomDialog(onChanged: (() -> Unit)? = null) { + BrowserUiHelper.showZoomDialog(this, currentZoomLevel) { newZoom -> + applyZoom(newZoom) + getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE).edit().putInt(KEY_ZOOM_LEVEL, newZoom).apply() + onChanged?.invoke() + } } internal fun applyZoom(zoomPercent: Int) { @@ -1755,31 +691,20 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { webView.settings.textZoom = zoomPercent } - /** 切换 UA(移动手机版 / 电脑桌面版):UA 字符串、Client Hints、电脑版宽视口一起切换 */ internal fun toggleUserAgent() { isDesktopUa = !isDesktopUa UserAgentHelper.applyUserAgent(webView.settings, isDesktopUa) desktopViewportScript = UserAgentHelper.setDesktopViewport(webView, isDesktopUa, desktopViewportScript) - getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE) - .edit().putBoolean(KEY_IS_DESKTOP_UA, isDesktopUa).apply() - Toast.makeText( - this, - if (isDesktopUa) "已切换为:电脑桌面版(宽屏排版)" else "已切换为:移动手机版", - Toast.LENGTH_SHORT - ).show() - if (!isOnHomePage) { - webView.reload() - } + getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE).edit().putBoolean(KEY_IS_DESKTOP_UA, isDesktopUa).apply() + val msg = if (isDesktopUa) "已切换为:电脑桌面版(宽屏排版)" else "已切换为:移动手机版" + Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() + if (!isOnHomePage) webView.reload() } - /** 系统 WebView 不支持页面执行前注入时,电脑版视口脚本在页面回调里尽早注入(脚本内部防重) */ private fun injectDesktopModeFallback(view: WebView?) { - if (isDesktopUa && desktopViewportScript == null) { - view?.evaluateJavascript(UserAgentHelper.JS_DESKTOP_MODE, null) - } + if (isDesktopUa && desktopViewportScript == null) view?.evaluateJavascript(UserAgentHelper.JS_DESKTOP_MODE, null) } - /** 工具栏平滑显隐:全屏或首页时始终显示,网页模式向下滚动时隐藏以获得 100% 满屏视野 */ private fun setToolbarVisible(visible: Boolean) { if (isOnHomePage) { binding.toolbarScroll.visibility = View.VISIBLE @@ -1787,207 +712,52 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { isToolbarVisible = true return } - if (isAppFullscreen || fullscreenContainer != null) return + if (::fullscreenHelper.isInitialized && fullscreenHelper.isAnyFullscreen) return if (isToolbarVisible == visible) return isToolbarVisible = visible if (visible) { binding.toolbarScroll.visibility = View.VISIBLE - binding.toolbarScroll.animate() - .translationY(0f) - .setDuration(180) - .start() + binding.toolbarScroll.animate().translationY(0f).setDuration(180).start() } else { val h = binding.toolbarScroll.height.toFloat().coerceAtLeast(140f) - binding.toolbarScroll.animate() - .translationY(-h) - .setDuration(180) - .withEndAction { - if (!isToolbarVisible) binding.toolbarScroll.visibility = View.GONE - } - .start() + binding.toolbarScroll.animate().translationY(-h).setDuration(180) + .withEndAction { if (!isToolbarVisible) binding.toolbarScroll.visibility = View.GONE }.start() } } - /** 更新首页顶部状态条:时钟、网络与手机遥控入口(整行已完全隐藏) */ - private fun updateHomeStatusBar() { - binding.homeStatusBar.visibility = View.GONE - } + internal fun checkNetworkDiagnostics() = BrowserUiHelper.checkNetworkDiagnostics(this) - /** 检查网络诊断与互联网连通性 */ - internal fun checkNetworkDiagnostics() { - val progress = AlertDialog.Builder(this) - .setTitle(getString(R.string.check_network)) - .setMessage(getString(R.string.network_checking)) - .setCancelable(false) - .create() - progress.show() - - Thread { - val linkStatus = NetworkChecker.getStatusMessage(this) - val internetReachable = NetworkChecker.isInternetReachable() - val internetStatus = if (internetReachable) { - getString(R.string.internet_ok) - } else { - getString(R.string.internet_unreachable) - } - val currentYear = java.util.Calendar.getInstance().get(java.util.Calendar.YEAR) - val timeTip = if (currentYear < 2024) "\n⚠️ 提示:检测到电视系统年份为 ${currentYear} 年,系统时钟可能未同步,浏览器已开启 SSL 兼容放行模式。" else "" - runOnUiThread { - if (!isFinishing && !isDestroyed) { - progress.dismiss() - AlertDialog.Builder(this) - .setTitle(getString(R.string.check_network)) - .setMessage("$linkStatus\n$internetStatus$timeTip") - .setPositiveButton(getString(R.string.close), null) - .show() - } - } - }.start() - } - - /** 切换卡片整理模式:显示/隐藏所有卡片上的 X 删除按钮 */ private fun toggleCardEditing() { if (!isOnHomePage) return isEditingCards = !isEditingCards - binding.btnMouseMode.text = getString( - if (isEditingCards) R.string.card_edit_done else R.string.card_edit - ) + binding.btnMouseMode.text = getString(if (isEditingCards) R.string.card_edit_done else R.string.card_edit) (binding.homeGrid.adapter as? HomeCardAdapter)?.setEditing(isEditingCards) } - /** 退出卡片整理模式 */ private fun exitCardEditing() { isEditingCards = false binding.btnMouseMode.text = getString(R.string.card_edit) (binding.homeGrid.adapter as? HomeCardAdapter)?.setEditing(false) } - /** 将光标限制在全屏 overlay 内(如全屏切换后);仅在 overlay 已测量且光标越界时才收紧,避免底部左右移动时触发布局导致 Y 被错误改动 */ - private fun clampCursorToBounds() { - val overlay = cursorOverlay ?: return - val cursor = cursorView ?: return - if (overlay.width <= 0 || overlay.height <= 0) return - val maxX = (overlay.width - cursor.width).toFloat().coerceAtLeast(0f) - val maxY = (overlay.height - cursor.height).toFloat().coerceAtLeast(0f) - val newX = cursorX.coerceIn(0f, maxX) - val newY = cursorY.coerceIn(0f, maxY) - if (newX != cursorX || newY != cursorY) { - cursorX = newX - cursorY = newY - cursor.x = cursorX - cursor.y = cursorY - } + private fun isDialogVisible(): Boolean = DIALOG_FRAGMENT_TAGS.any { tag -> + (supportFragmentManager.findFragmentByTag(tag) as? DialogFragment)?.isAdded == true } - /** 在光标位置向整页(contentLayout)派发一次点击,可点到工具栏或 WebView */ - private fun dispatchClickAtCursor() { - val content = contentLayout ?: return - val now = SystemClock.uptimeMillis() - val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, cursorX, cursorY, 0) - content.dispatchTouchEvent(down) - down.recycle() - val up = MotionEvent.obtain(now, now + 50, MotionEvent.ACTION_UP, cursorX, cursorY, 0) - content.dispatchTouchEvent(up) - up.recycle() - } - - /** 焦点是否在网页内容区且应使用模拟鼠标(首页卡片或工具栏按钮走正常 D-pad 焦点流转) */ - private fun isFocusInContent(focus: View?): Boolean { - // 首页卡片模式:绝不拦截,必须走原生 TV 焦点流转 - if (isOnHomePage) return false - // 网页模式下若焦点为 null,默认交给鼠标 - if (focus == null) return true - var v: View? = focus - while (v != null) { - // 工具栏内部控件(后退、主页、刷新、收藏、地址栏、前往等)走普通按键焦点 - if (v == binding.toolbar) return false - // 网页容器内则由模拟鼠标接管 - if (v == binding.webContainer || v == webView) return true - v = v.parent as? View - } - return false - } - - /** 是否有弹窗正在显示(方向键应交给弹窗内焦点,不在 Activity 里用于移动光标) */ - private fun isDialogVisible(): Boolean { - for (tag in DIALOG_FRAGMENT_TAGS) { - (supportFragmentManager.findFragmentByTag(tag) as? DialogFragment)?.takeIf { it.isAdded }?.let { return true } - } - return false - } - - /** 当前网页是否为遥控页(127.0.0.1:端口),TV 上打开时需把方向键交给页面处理 */ private fun isRemoteControlPage(): Boolean { val url = webView.url ?: return false - return try { - val uri = android.net.Uri.parse(url) + return runCatching { + val uri = Uri.parse(url) uri.host == "127.0.0.1" && uri.port == getRemoteControlPort() - } catch (_: Exception) { false } + }.getOrDefault(false) } - /** - * 首页"继续观看"追剧列表的遥控器焦点: - * - 工具栏任意按钮按 ↓ → 追剧列表第一张卡片 - * - 第一行收藏卡片按 ↑ → 追剧列表第一张卡片 - * - 追剧卡片按 ↓ → 第一张收藏卡片;按 ↑ → 工具栏 - * @return true 表示已处理 - */ - private fun handleHomeBannerFocus(keyCode: Int): Boolean { - if (!isOnHomePage || binding.layoutContinueWatching.visibility != View.VISIBLE) return false - val focus = currentFocus ?: return false - fun View.isInside(parent: View): Boolean { - var v: View? = this - while (v != null) { - if (v == parent) return true - v = v.parent as? View - } - return false - } - when (keyCode) { - KeyEvent.KEYCODE_DPAD_DOWN -> { - if (focus.isInside(binding.toolbar)) { - return focusContinueList() - } - if (focus.isInside(binding.layoutContinueWatching)) { - val first = binding.homeGrid.findViewHolderForAdapterPosition(0)?.itemView - return first?.requestFocus() ?: true - } - } - KeyEvent.KEYCODE_DPAD_UP -> { - if (focus.isInside(binding.homeGrid) && focus != binding.homeGrid) { - val item = binding.homeGrid.findContainingItemView(focus) ?: return false - val pos = binding.homeGrid.getChildAdapterPosition(item) - val span = (binding.homeGrid.layoutManager as? GridLayoutManager)?.spanCount ?: 1 - if (pos in 0 until span) return focusContinueList() - } - if (focus.isInside(binding.layoutContinueWatching)) { - return binding.btnMouseMode.requestFocus() - } - } - KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> { - // 追剧列表两端不让焦点跳出到别的区域 - if (focus.isInside(binding.continueList)) { - val item = binding.continueList.findContainingItemView(focus) ?: return false - val pos = binding.continueList.getChildAdapterPosition(item) - val last = (binding.continueList.adapter?.itemCount ?: 0) - 1 - if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT && pos == 0) return true - if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT && pos == last) return true - } - } - } - return false - } - - /** 拦截遥控器按键:全屏视频时控制播放;否则焦点在内容区时用 D-pad 移动光标、确认键点击 */ override fun dispatchKeyEvent(event: KeyEvent?): Boolean { - // 弹窗显示时:让系统正常派发给弹窗窗口 if (isDialogVisible()) return super.dispatchKeyEvent(event) - - // 首页"继续观看"横幅:按钮在右侧,系统焦点搜索会优先选正下方/正上方的控件而跳过它,这里手动接管 - if (event != null && event.action == KeyEvent.ACTION_DOWN && handleHomeBannerFocus(event.keyCode)) return true - - // 遥控页:拦截 D-pad 并直接调用网页的全局函数(Android D-pad 不会自动映射到网页键盘事件) - if (event != null && isRemoteControlPage() && isFocusInContent(currentFocus)) { + if (event != null && event.action == KeyEvent.ACTION_DOWN && + BrowserUiHelper.handleHomeBannerFocus(event.keyCode, isOnHomePage, currentFocus, binding) { focusContinueList() } + ) return true + if (event != null && isRemoteControlPage() && cursorController.isFocusInContent(currentFocus, isOnHomePage)) { if (event.action == KeyEvent.ACTION_DOWN) { val jsKey = when (event.keyCode) { KeyEvent.KEYCODE_DPAD_UP -> "ArrowUp" @@ -1997,562 +767,162 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { else -> null } if (jsKey != null) { - // 直接调用网页暴露的全局函数 handleDpadKey webView.evaluateJavascript("if (typeof handleDpadKey === 'function') handleDpadKey('$jsKey');", null) return true } } - // 其他按键(如 Enter)交给 WebView 处理 return super.dispatchKeyEvent(event) } - - // 菜单键 (Menu):在网页模式下若工具栏隐藏,优先唤出工具栏;若已显示则弹出更多菜单 if (event != null && event.action == KeyEvent.ACTION_DOWN && event.keyCode == KeyEvent.KEYCODE_MENU) { if (!isOnHomePage && !isToolbarVisible) { setToolbarVisible(true) return true } } - - // 视频全屏(CustomView)时:遥控器控制播放/暂停、快进/快退、音量 - if (event != null && event.action == KeyEvent.ACTION_DOWN && fullscreenContainer != null && fullscreenContainer?.parent != null) { - when (event.keyCode) { - KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> - runVideoJs(VideoControlHelper.JS_TOGGLE_PLAY) { } - KeyEvent.KEYCODE_DPAD_LEFT -> - runVideoJs(VideoControlHelper.JS_SEEK_BACK) { } - KeyEvent.KEYCODE_DPAD_RIGHT -> - runVideoJs(VideoControlHelper.JS_SEEK_FORWARD) { } - KeyEvent.KEYCODE_DPAD_UP -> - runVideoJs(VideoControlHelper.JS_VOLUME_UP) { } - KeyEvent.KEYCODE_DPAD_DOWN -> - runVideoJs(VideoControlHelper.JS_VOLUME_DOWN) { } - else -> { /* 其他键如返回在 onBackPressed 处理 */ } - } - if (event.keyCode in listOf( - KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, - KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT, - KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN - )) return true - } - - val focusInContent = isFocusInContent(currentFocus) - if (event != null && isMouseMode && focusInContent) { - val keyCode = event.keyCode - val action = event.action - if (action == KeyEvent.ACTION_DOWN) { - val repeat = event.repeatCount - val accelStep = (cursorStepPx * (1f + (repeat * 0.35f).coerceAtMost(3.0f))).toInt() - when (keyCode) { - KeyEvent.KEYCODE_DPAD_LEFT -> { moveCursor(-accelStep, 0); return true } - KeyEvent.KEYCODE_DPAD_RIGHT -> { moveCursor(accelStep, 0); return true } - KeyEvent.KEYCODE_DPAD_UP -> { moveCursor(0, -accelStep); return true } - KeyEvent.KEYCODE_DPAD_DOWN -> { moveCursor(0, accelStep); return true } - KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> return true - } - } else if (action == KeyEvent.ACTION_UP) { - if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { - dispatchClickAtCursor() - return true - } - if (keyCode in listOf( - KeyEvent.KEYCODE_DPAD_LEFT, - KeyEvent.KEYCODE_DPAD_RIGHT, - KeyEvent.KEYCODE_DPAD_UP, - KeyEvent.KEYCODE_DPAD_DOWN - )) { - return true - } - } - } + if (event != null && fullscreenHelper.handleKeyEvent(event)) return true + if (event != null && cursorController.handleKeyEvent(event, isOnHomePage, isMouseMode, currentFocus) { setToolbarVisible(true) }) return true return super.dispatchKeyEvent(event) } - /** 进入全屏 CustomView 处理:支持机型差异、沉浸模式并隐藏工具栏与光标 */ - private fun handleShowCustomView(view: View?, callback: WebChromeClient.CustomViewCallback?) { - if (view == null || callback == null) return - exitCustomViewFullscreen() + fun enterAppFullscreen() = fullscreenHelper.enterAppFullscreen() + fun exitAppFullscreen() = fullscreenHelper.exitAppFullscreen() - // 隐藏工具栏,启用沉浸式全屏 - binding.toolbarScroll.visibility = View.GONE - setImmersiveFullscreen(true) - - fullscreenContainer = FrameLayout(this).apply { - setBackgroundColor(Color.BLACK) - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - } - fullscreenContainer!!.addView( - view, - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - ) - (findViewById(android.R.id.content)).addView(fullscreenContainer) - customViewCallback = callback - // 全屏视频期间方向键用于控制播放,隐藏光标(必须在 fullscreenContainer 赋值后调用) - setCursorOverlayVisible(false, customViewCursorToken) - Toast.makeText(this, "全屏播放:确认键暂停,左右键快进快退,返回键退出全屏", Toast.LENGTH_SHORT).show() - } - - /** 退出 WebView 全屏视频(CustomView),并通知 WebView */ - private fun exitCustomViewFullscreen() { - val wasFullscreen = fullscreenContainer != null - fullscreenContainer?.let { container -> - (container.parent as? ViewGroup)?.removeView(container) - container.removeAllViews() - } - fullscreenContainer = null - customViewCallback?.onCustomViewHidden() - customViewCallback = null - - // 恢复工具栏与光标(若未处于应用级全屏) - if (wasFullscreen && !isAppFullscreen) { - binding.toolbarScroll.visibility = View.VISIBLE - setImmersiveFullscreen(false) - } - setCursorOverlayVisible(true, customViewCursorToken) - if (wasFullscreen) { - // 布局变化后把光标拉回可见区域,并让网页重新获得焦点以便继续用方向键移动光标 - cursorOverlay?.post { clampCursorToBounds() } - webView.requestFocus() - } - } - - /** 设置沉浸式系统全屏模式 */ - @Suppress("DEPRECATION") - private fun setImmersiveFullscreen(enable: Boolean) { - if (enable) { - window.addFlags(android.view.WindowManager.LayoutParams.FLAG_FULLSCREEN) - window.decorView.systemUiVisibility = ( - View.SYSTEM_UI_FLAG_LAYOUT_STABLE - or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - or View.SYSTEM_UI_FLAG_FULLSCREEN - or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - ) - } else { - window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_FULLSCREEN) - window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE - } - } - - /** 进入应用级全屏:隐藏工具栏,让 WebView 占满屏幕(网页全屏或机型全屏降级) */ - fun enterAppFullscreen() { - if (isAppFullscreen) return - isAppFullscreen = true - binding.toolbarScroll.visibility = View.GONE - setImmersiveFullscreen(true) - cursorOverlay?.post { clampCursorToBounds() } - Toast.makeText(this, getString(R.string.video_fullscreen_app_mode), Toast.LENGTH_SHORT).show() - } - - /** 退出应用级全屏:恢复工具栏 */ - fun exitAppFullscreen() { - if (!isAppFullscreen) return - isAppFullscreen = false - binding.toolbarScroll.visibility = View.VISIBLE - setImmersiveFullscreen(false) - cursorOverlay?.post { clampCursorToBounds() } - } - - /** 切换应用级全屏 */ - private fun toggleAppFullscreen() { - if (isAppFullscreen) exitAppFullscreen() else enterAppFullscreen() - } - - /** JavaScript 注入桥接器:网页点击全屏失败或使用网页全屏时无缝通知宿主 App */ inner class FullscreenBridge { - @android.webkit.JavascriptInterface - fun onEnterFullscreen(isNative: Boolean) { - runOnUiThread { - Log.d(TAG_VIDEO, "FullscreenBridge: onEnterFullscreen(isNative=$isNative)") - if (!isNative && fullscreenContainer == null && !isAppFullscreen) { - enterAppFullscreen() - } - } + @JavascriptInterface + fun onEnterFullscreen(isNative: Boolean) = runOnUiThread { + if (!isNative && !fullscreenHelper.isAnyFullscreen) fullscreenHelper.enterAppFullscreen() } - @android.webkit.JavascriptInterface - fun onExitFullscreen() { - runOnUiThread { - Log.d(TAG_VIDEO, "FullscreenBridge: onExitFullscreen") - if (isAppFullscreen) { - exitAppFullscreen() - } - } + @JavascriptInterface + fun onExitFullscreen() = runOnUiThread { + if (fullscreenHelper.isAppFullscreen) fullscreenHelper.exitAppFullscreen() } - @android.webkit.JavascriptInterface + @JavascriptInterface fun onFullscreenAttempted() { mainHandler.postDelayed({ - if (fullscreenContainer == null && !isAppFullscreen && !isOnHomePage) { - Log.d(TAG_VIDEO, "Fullscreen timeout: fallback to app fullscreen") - enterAppFullscreen() - } + if (!fullscreenHelper.isAnyFullscreen && !isOnHomePage) fullscreenHelper.enterAppFullscreen() }, 800) } } - /** 防跳转劫持桥接:页面在捕获阶段上报用户真实点击的链接(未点在链接上时为空串) */ inner class NavGuardBridge { - @android.webkit.JavascriptInterface + @JavascriptInterface fun onUserLink(href: String) { lastUserLink = href lastUserLinkTime = SystemClock.uptimeMillis() } } - /** JavaScript 视频嗅探桥接器:网页内部发现播放器与直链时通知宿主 */ inner class VideoBridge { - @android.webkit.JavascriptInterface - fun onVideoFound(url: String, title: String) { - onVideoSniffed(url, title, "dom") - } + @JavascriptInterface + fun onVideoFound(url: String, title: String) = onVideoSniffed(url, title, "dom") - /** document-start Hook 上报:XHR/fetch/hls.js/video.src 发出的媒体请求 */ - @android.webkit.JavascriptInterface - fun onMediaRequest(url: String, kind: String, frameUrl: String, title: String) { + @JavascriptInterface + fun onMediaRequest(url: String, kind: String, frameUrl: String, title: String) = onVideoSniffed(url, title, kind, frameUrl.takeIf { it.startsWith("http") }) - } - /** Hook 上报:网页已下载的 m3u8 清单原文(一次性链接时原生播放器直接用这份内容) */ - @android.webkit.JavascriptInterface + @JavascriptInterface fun onPlaylist(url: String, finalUrl: String, text: String) { PlaylistCache.put(url, finalUrl, text) Log.d(TAG_VIDEO, "Playlist captured: $url (${text.length} chars)") } - /** 站点适配器上报:片名、当前集、选集列表 */ - @android.webkit.JavascriptInterface + @JavascriptInterface fun onSiteInfo(json: String) { val info = SiteInfo.fromJson(json) ?: return runOnUiThread { - currentSiteInfo = info - currentSiteInfoJson = json + snifferController.currentSiteInfo = info + snifferController.currentSiteInfoJson = json Log.d(TAG_VIDEO, "Site info[${info.adapter}]: ${info.displayTitle()} episodes=${info.episodes.size}") if (AppSettings.isPurifyPage(this@BrowserActivity)) applyPurify(resetDisabled = false) } } } - /** 净化播放页按钮回调(JS 线程 → 主线程) */ inner class PurifyBridge { - /** 全屏:有可原生播放的视频源时用原生播放器,否则让网页播放器全屏(如央视网加密流) */ - @android.webkit.JavascriptInterface - fun onFullscreen() { - runOnUiThread { - if (sniffedVideoList.isNotEmpty() || !currentSniffedVideoUrl.isNullOrBlank()) { - launchNativePlayer() - } else { - webView.evaluateJavascript(PurifyHelper.JS_WEB_FULLSCREEN, null) - } - } + @JavascriptInterface + fun onFullscreen() = runOnUiThread { + if (snifferController.hasAnyVideo) snifferController.launchNativePlayer() + else webView.evaluateJavascript(PurifyHelper.JS_WEB_FULLSCREEN, null) } - @android.webkit.JavascriptInterface - fun onBookmark() { - runOnUiThread { addCurrentToBookmarks() } - } + @JavascriptInterface + fun onBookmark() = runOnUiThread { addCurrentToBookmarks() } } - /** 用当前页的站点适配结果执行净化(脚本内部判断是否为播放页、是否找得到播放器) */ private fun applyPurify(resetDisabled: Boolean) { if (isOnHomePage) return - val json = currentSiteInfoJson ?: return + val json = snifferController.currentSiteInfoJson ?: return val prefix = if (resetDisabled) "window.__tvPurifyDisabled=false;\n" else "" webView.evaluateJavascript(prefix + PurifyHelper.buildApplyScript(json), null) } - /** 净化播放页开关:保存设置并立即作用于当前页面 */ internal fun togglePurifyPage() { val enable = !AppSettings.isPurifyPage(this) AppSettings.setPurifyPage(this, enable) if (!isOnHomePage) { - if (enable) applyPurify(resetDisabled = true) - else webView.evaluateJavascript(PurifyHelper.JS_RESTORE, null) + if (enable) applyPurify(resetDisabled = true) else webView.evaluateJavascript(PurifyHelper.JS_RESTORE, null) } - Toast.makeText( - this, - if (enable) "已开启净化播放页:识别到播放页时只保留播放器与选集" else "已关闭净化播放页", - Toast.LENGTH_SHORT - ).show() + val msg = if (enable) "已开启净化播放页:识别到播放页时只保留播放器与选集" else "已关闭净化播放页" + Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() } - override fun onRemoteMove(dx: Int, dy: Int) { - runOnUiThread { moveCursor(dx, dy) } + override fun onRemoteMove(dx: Int, dy: Int) = + runOnUiThread { cursorController.move(dx, dy, isOnHomePage) { setToolbarVisible(true) } } + + override fun onRemoteClick() = runOnUiThread { + if (!fullscreenHelper.handleRemoteClick()) cursorController.dispatchClick() } - override fun onRemoteClick() { - runOnUiThread { - // 全屏视频时:优先在全屏容器中心模拟一次点击,让视频播放器按自身逻辑处理(通常为播放/暂停) - if (fullscreenContainer != null && fullscreenContainer?.parent != null) { - try { - val container = fullscreenContainer!! - val target = if (container.childCount > 0) container.getChildAt(0) else container - val w = target.width - val h = target.height - if (w > 0 && h > 0) { - val now = SystemClock.uptimeMillis() - val x = (w / 2f) - val y = (h / 2f) - val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0) - val up = MotionEvent.obtain(now, now + 50, MotionEvent.ACTION_UP, x, y, 0) - target.dispatchTouchEvent(down) - target.dispatchTouchEvent(up) - down.recycle() - up.recycle() - return@runOnUiThread - } - } catch (_: Exception) { - // 回退到 JS 控制 - runVideoJs(VideoControlHelper.JS_TOGGLE_PLAY) { } - return@runOnUiThread - } - // 如果无法获取有效尺寸,则回退到 JS 控制 - runVideoJs(VideoControlHelper.JS_TOGGLE_PLAY) { } - } else { - // 普通模式:在当前光标位置点击 - dispatchClickAtCursor() - } - } - } + override fun onRemoteScroll(dx: Int, dy: Int) = runOnUiThread { webView.scrollBy(dx, dy) } - override fun onRemoteScroll(dx: Int, dy: Int) { - runOnUiThread { webView.scrollBy(dx, dy) } - } - - override fun onRemoteKey(keyCode: String) { - runOnUiThread { - when (keyCode) { - "back" -> onBackPressed() - "home" -> showHomePage() - "menu" -> { - if (!isOnHomePage && !isToolbarVisible) { - setToolbarVisible(true) - } else { - showMoreMenu() - } - } - "refresh" -> webView.reload() - "fullscreen" -> triggerVideoFullscreen() - "volume_up" -> { - val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager - audioManager?.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_RAISE, AudioManager.FLAG_SHOW_UI) - } - "volume_down" -> { - val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager - audioManager?.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_LOWER, AudioManager.FLAG_SHOW_UI) - } - "mute" -> { - val audioManager = getSystemService(Context.AUDIO_SERVICE) as? AudioManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - audioManager?.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_TOGGLE_MUTE, AudioManager.FLAG_SHOW_UI) - } else { - @Suppress("DEPRECATION") - audioManager?.setStreamMute(AudioManager.STREAM_MUSIC, true) - } - } - "dpad_left" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT)) - "dpad_right" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT)) - "dpad_up" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP)) - "dpad_down" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN)) - "native_play" -> launchNativePlayer() - "external_play" -> launchExternalPlayer() - else -> { } - } + override fun onRemoteKey(keyCode: String) = runOnUiThread { + when (keyCode) { + "back" -> onBackPressed() + "home" -> showHomePage() + "menu" -> if (!isOnHomePage && !isToolbarVisible) setToolbarVisible(true) else showMoreMenu() + "refresh" -> webView.reload() + "fullscreen" -> fullscreenHelper.triggerVideoFullscreen() + "volume_up" -> remoteHelper.adjustVolume(raise = true) + "volume_down" -> remoteHelper.adjustVolume(raise = false) + "mute" -> remoteHelper.toggleMute() + "dpad_left" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT)) + "dpad_right" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT)) + "dpad_up" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP)) + "dpad_down" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN)) + "native_play" -> snifferController.launchNativePlayer() + "external_play" -> snifferController.launchExternalPlayer() } } override fun onRemoteOpenUrl(url: String) { - val trimmed = url.trim() - if (trimmed.isEmpty()) return + val trimmed = url.trim().takeIf { it.isNotEmpty() } ?: return runOnUiThread { - val targetUrl = when { - !trimmed.startsWith("http://", ignoreCase = true) && !trimmed.startsWith("https://", ignoreCase = true) -> "https://$trimmed" - else -> trimmed - } + val targetUrl = if (!trimmed.startsWith("http://", true) && !trimmed.startsWith("https://", true)) "https://$trimmed" else trimmed urlBar.setText(targetUrl) loadUrl(targetUrl) Toast.makeText(this, "正在打开手机推送链接...", Toast.LENGTH_SHORT).show() } } - override fun onRemoteText(text: String) { - if (text.isEmpty()) return - runOnUiThread { - // 1)优先写入当前获得焦点的原生 EditText(地址栏、弹窗输入框等) - val focusedEdit = currentFocus as? EditText - if (focusedEdit != null) { - val editable = focusedEdit.text - editable.replace(0, editable.length, text) - focusedEdit.requestFocus() - focusedEdit.setSelection(text.length) - return@runOnUiThread - } + override fun onRemoteText(text: String) = + runOnUiThread { remoteHelper.handleRemoteText(text, currentFocus, isOnHomePage, webView, urlBar) } - // 2)当前在网页中输入:将文字注入 WebView 内页面的当前输入框(activeElement) - if (!isOnHomePage) { - val jsText = text - .replace("\\", "\\\\") - .replace("'", "\\'") - .replace("\n", "\\n") - .replace("\r", "") - val js = """ - (function() { - var el = document.activeElement; - if (!el || (!el.isContentEditable && ['INPUT','TEXTAREA'].indexOf(el.tagName) === -1)) { - var candidates = Array.prototype.slice.call( - document.querySelectorAll('input, textarea, [contenteditable=""], [contenteditable="true"]') - ); - el = candidates.find(function(f) { - return !f.disabled && f.type !== 'hidden' && f.offsetParent !== null; - }); - } - if (!el) return; - var insert = '$jsText'; - if (typeof el.value === 'string') el.value = insert; - else el.textContent = insert; - var caret = insert.length; - if (el.setSelectionRange) { - try { el.setSelectionRange(caret, caret); } catch (e) {} - } - if (typeof el.focus === 'function') el.focus(); - try { - var evInput = new Event('input', { bubbles: true }); - el.dispatchEvent(evInput); - } catch (e) { - try { - var ev = document.createEvent('Event'); - ev.initEvent('input', true, true); - el.dispatchEvent(ev); - } catch (e2) {} - } - })(); - """.trimIndent() - webView.evaluateJavascript(js, null) - return@runOnUiThread - } + override fun onRemoteFileUploaded(file: File, filename: String) = + runOnUiThread { remoteHelper.handleFileUploaded(file, filename) } - // 3)回退:写入地址栏 - val target = urlBar - val editable = target.text - editable.replace(0, editable.length, text) - target.requestFocus() - target.setSelection(text.length) - } - } + internal fun showRemoteControlDialog() = RemoteControlDialog().show(supportFragmentManager, "remote") - /** 局域网快传接收:处理 APK 安装包及音视频/普通文件 (P5) */ - override fun onRemoteFileUploaded(file: File, filename: String) { - runOnUiThread { - val lower = filename.lowercase() - if (lower.endsWith(".apk")) { - promptInstallApk(file, filename) - } else if (lower.endsWith(".mp4") || lower.endsWith(".m3u8") || lower.endsWith(".mkv") || lower.endsWith(".flv")) { - AlertDialog.Builder(this) - .setTitle("🎬 收到手机快传视频") - .setMessage("文件:$filename\n大小:${file.length() / 1024 / 1024}MB\n是否立即全屏播放?") - .setPositiveButton("立即播放") { _, _ -> - PlayerActivity.start(this, Uri.fromFile(file).toString(), filename) - } - .setNegativeButton("稍后", null) - .show() - } else { - Toast.makeText(this, "收到手机文件:$filename (已存入下载目录)", Toast.LENGTH_LONG).show() - } - } - } - - private fun promptInstallApk(file: File, filename: String) { - try { - val uri = FileProvider.getUriForFile( - this, - "${packageName}.fileprovider", - file - ) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "application/vnd.android.package-archive") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) - } - AlertDialog.Builder(this) - .setTitle("📱 收到手机快传 APK 安装包") - .setMessage("应用文件:$filename\n大小:${file.length() / 1024 / 1024}MB\n是否立即安装?") - .setPositiveButton("立即安装") { _, _ -> - try { - startActivity(intent) - } catch (e: Exception) { - Log.e(TAG_VIDEO, "Failed to launch package installer", e) - Toast.makeText(this, "打开安装器失败: ${e.message}", Toast.LENGTH_LONG).show() - } - } - .setNegativeButton("稍后", null) - .show() - } catch (e: Exception) { - Log.e(TAG_VIDEO, "FileProvider error for APK", e) - Toast.makeText(this, "解析安装包异常: ${e.message}", Toast.LENGTH_SHORT).show() - } - } - - internal fun showRemoteControlDialog() { - RemoteControlDialog().show(supportFragmentManager, "remote") - } - - /** 初始化遥控状态小圆点(圆形背景) */ - private fun initRemoteStatusDot() { - val dot = binding.remoteStatusDot - dot.background = GradientDrawable().apply { - shape = GradientDrawable.OVAL - setColor(Color.RED) - } - } - - /** 根据遥控服务运行状态更新小圆点颜色:绿色=运行中,红色=未运行 */ - private fun updateRemoteStatusDot() { - val running = isRemoteServerRunning() - val dot = binding.remoteStatusDot - (dot.background as? GradientDrawable)?.setColor( - if (running) Color.parseColor("#4CAF50") else Color.RED - ) - } - - /** 确保遥控服务已启动(应用打开时在 onCreate 调用) */ - fun ensureRemoteControlServerStarted(): Boolean { - val s = remoteControlServer - if (s != null && s.isServerRunning()) return true - try { - remoteControlServer?.stop() - } catch (_: Exception) { } - remoteControlServer = RemoteControlServer(this, mainHandler, this).apply { start() } - return remoteControlServer?.isServerRunning() == true - } - - /** 遥控服务是否在运行(供弹窗状态检测) */ - fun isRemoteServerRunning(): Boolean = remoteControlServer?.isServerRunning() == true - - /** 遥控服务端口(供弹窗生成 URL) */ - fun getRemoteControlPort(): Int = remoteControlServer?.port ?: 8766 - - /** 重启遥控服务(供弹窗「重启服务」按钮调用) */ - fun restartRemoteControlServer(): Boolean { - try { - remoteControlServer?.stop() - } catch (_: Exception) { } - remoteControlServer = null - return ensureRemoteControlServerStarted() - } + fun ensureRemoteControlServerStarted(): Boolean = remoteHelper.ensureStarted() + fun isRemoteServerRunning(): Boolean = remoteHelper.isRunning() + fun getRemoteControlPort(): Int = remoteHelper.getPort() + fun restartRemoteControlServer(): Boolean = remoteHelper.restart() override fun onDestroy() { vodSearchHelper?.cancel() mainHandler.removeCallbacks(remoteStatusChecker) - try { - remoteControlServer?.stop() - } catch (_: Exception) { } - remoteControlServer = null + if (::remoteHelper.isInitialized) remoteHelper.stop() dialogCursorLifecycleCallbacks?.let { supportFragmentManager.unregisterFragmentLifecycleCallbacks(it) } dialogCursorLifecycleCallbacks = null try { @@ -2565,21 +935,17 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { override fun onRestart() { super.onRestart() - // 从桌面重新打开 App 时:若播放器正以画中画小窗播放,恢复为全屏播放器 PlayerActivity.restoreFromPipIfNeeded(this) } override fun onResume() { super.onResume() isActivityResumed = true - // 从后台或原生 / 外部播放器回来:恢复 WebView 与网页加载 if (::webView.isInitialized) { webView.onResume() resumeWebMediaIfSuspended() } - // 从播放器返回首页时刷新追剧列表(进度 / 下一集) if (isOnHomePage && ::binding.isInitialized) updateContinueWatchingBanner() - // 自愈:回到前台时重新计算光标显示状态 if (::binding.isInitialized) updateCursorOverlayVisibility() } @@ -2605,12 +971,10 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { @Deprecated("Deprecated in Java") override fun onBackPressed() { - // 自动接管播放倒计时中:返回键只取消倒计时 if (cancelAutoPlay(markHandled = true)) { Toast.makeText(this, "已取消自动全屏播放", Toast.LENGTH_SHORT).show() return } - // 若有弹窗显示,返回键先关闭弹窗,而不是退出应用 for (tag in DIALOG_FRAGMENT_TAGS) { (supportFragmentManager.findFragmentByTag(tag) as? DialogFragment)?.takeIf { it.isAdded }?.let { it.dismiss() @@ -2618,15 +982,12 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback { } } when { - fullscreenContainer != null && fullscreenContainer?.parent != null -> { - exitCustomViewFullscreen() - } - isAppFullscreen -> { - exitAppFullscreen() + fullscreenHelper.isCustomViewActive -> fullscreenHelper.exitCustomView() + fullscreenHelper.isAppFullscreen -> { + fullscreenHelper.exitAppFullscreen() runVideoJs(VideoControlHelper.JS_EXIT_FULLSCREEN) { } } webView.canGoBack() -> webView.goBack() - // WebView 不能后退但当前在网页模式:先回到首页,而不是直接退出应用 !isOnHomePage -> showHomePage() else -> super.onBackPressed() } diff --git a/app/src/main/java/com/tv/browser/BrowserUiHelper.kt b/app/src/main/java/com/tv/browser/BrowserUiHelper.kt new file mode 100644 index 0000000..1d6c2a5 --- /dev/null +++ b/app/src/main/java/com/tv/browser/BrowserUiHelper.kt @@ -0,0 +1,523 @@ +package com.tv.browser + +import android.app.UiModeManager +import android.content.Context +import android.content.res.Configuration +import android.net.Uri +import android.os.SystemClock +import android.util.TypedValue +import android.view.KeyEvent +import android.view.View +import android.view.inputmethod.InputMethodManager +import android.webkit.WebStorage +import android.webkit.WebView +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.LinearLayoutManager +import com.tv.browser.databinding.ActivityBrowserBinding +import java.io.File +import java.util.Calendar + +/** + * 浏览器界面与设置弹窗辅助组件(从 BrowserActivity 拆分): + * 负责 TV/手机自适应响应式布局、首页追剧横幅焦点流转、缓存与上传目录清理、以及各类设置/诊断对话框。 + */ +object BrowserUiHelper { + + private const val FILTER_MENU_TEXT = "🛡️ 不良动图过滤" + private const val VOD_SEARCH_MENU_TEXT = "🔎 全网搜片(在收藏的影视站里搜)" + + fun isTelevisionDevice(context: Context): Boolean { + val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager + return uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION + } + + fun isCompactScreen(context: Context): Boolean { + if (isTelevisionDevice(context)) return false + return context.resources.configuration.screenWidthDp < 600 + } + + fun calculateHomeSpanCount(context: Context): Int { + if (isTelevisionDevice(context)) return 5 + val widthDp = context.resources.configuration.screenWidthDp + return when { + widthDp >= 900 -> 5 + widthDp >= 700 -> 4 + widthDp >= 480 -> 3 + else -> 2 + } + } + + private fun dp(context: Context, value: Int): Int = + TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), context.resources.displayMetrics).toInt() + + /** 自适应屏幕布局:TV 保持 5 列大按钮,手机窄屏缩减列数与按钮尺寸 */ + fun applyResponsiveLayout(context: Context, binding: ActivityBrowserBinding) { + val compact = isCompactScreen(context) + (binding.homeGrid.layoutManager as? GridLayoutManager)?.spanCount = calculateHomeSpanCount(context) + + if (compact) { + binding.homeGrid.setPadding(dp(context, 12), dp(context, 8), dp(context, 12), dp(context, 12)) + binding.continueHeader.setPadding(dp(context, 14), 0, dp(context, 14), 0) + binding.continueList.setPadding(dp(context, 12), dp(context, 1), dp(context, 12), dp(context, 2)) + binding.continueHint.text = "(点击续播 · 长按移除)" + binding.toolbar.setPadding(dp(context, 12), dp(context, 8), dp(context, 12), dp(context, 8)) + } else { + binding.homeGrid.setPadding(dp(context, 20), dp(context, 4), dp(context, 20), dp(context, 10)) + binding.continueHeader.setPadding(dp(context, 24), 0, dp(context, 24), 0) + binding.continueList.setPadding(dp(context, 20), dp(context, 1), dp(context, 20), dp(context, 2)) + binding.continueHint.text = "(确定键续播 · 长按移除)" + binding.toolbar.setPadding(dp(context, 24), dp(context, 12), dp(context, 24), dp(context, 12)) + } + + val btnHeight = if (compact) dp(context, 40) else dp(context, 48) + val btnMinWidth = if (compact) dp(context, 60) else dp(context, 80) + val btnTextSp = if (compact) 13f else 14f + val btnPadH = if (compact) dp(context, 10) else dp(context, 12) + + val normalButtons = listOf( + binding.btnMouseMode, + binding.btnHome, + binding.btnRefresh, + binding.btnBookmark, + binding.btnRemote, + binding.btnHistory, + binding.btnMore, + binding.btnGo + ) + normalButtons.forEach { btn -> + btn.layoutParams = btn.layoutParams.apply { height = btnHeight } + btn.minHeight = btnHeight + btn.minWidth = btnMinWidth + btn.setPadding(btnPadH, 0, btnPadH, 0) + btn.setTextSize(TypedValue.COMPLEX_UNIT_SP, btnTextSp) + } + + binding.btnNativePlay.let { btn -> + btn.layoutParams = btn.layoutParams.apply { height = btnHeight } + btn.minHeight = btnHeight + btn.minWidth = if (compact) dp(context, 76) else dp(context, 96) + btn.setPadding(btnPadH, 0, btnPadH, 0) + btn.setTextSize(TypedValue.COMPLEX_UNIT_SP, btnTextSp) + } + + binding.btnVoiceSearch.let { btn -> + btn.layoutParams = btn.layoutParams.apply { height = btnHeight } + btn.minHeight = btnHeight + btn.minWidth = if (compact) dp(context, 60) else dp(context, 76) + btn.setPadding(btnPadH, 0, btnPadH, 0) + btn.setTextSize(TypedValue.COMPLEX_UNIT_SP, btnTextSp) + } + + binding.remoteBtnContainer.layoutParams = binding.remoteBtnContainer.layoutParams.apply { + height = btnHeight + } + + (binding.urlBar.layoutParams as? LinearLayout.LayoutParams)?.let { lp -> + lp.width = if (compact) dp(context, 160) else dp(context, 180) + lp.height = btnHeight + lp.marginStart = if (compact) dp(context, 8) else dp(context, 16) + lp.marginEnd = if (compact) dp(context, 6) else dp(context, 8) + binding.urlBar.layoutParams = lp + } + val urlPadH = if (compact) dp(context, 12) else dp(context, 16) + binding.urlBar.setPadding(urlPadH, 0, urlPadH, 0) + binding.urlBar.setTextSize(TypedValue.COMPLEX_UNIT_SP, if (compact) 14f else 18f) + } + + /** 首页"继续观看"追剧列表的遥控器焦点流转 */ + fun handleHomeBannerFocus( + keyCode: Int, + isOnHomePage: Boolean, + currentFocus: View?, + binding: ActivityBrowserBinding, + focusContinueList: () -> Boolean + ): Boolean { + if (!isOnHomePage || binding.layoutContinueWatching.visibility != View.VISIBLE) return false + val focus = currentFocus ?: return false + fun View.isInside(parent: View): Boolean { + var v: View? = this + while (v != null) { + if (v == parent) return true + v = v.parent as? View + } + return false + } + when (keyCode) { + KeyEvent.KEYCODE_DPAD_DOWN -> { + if (focus.isInside(binding.toolbar)) return focusContinueList() + if (focus.isInside(binding.layoutContinueWatching)) { + val first = binding.homeGrid.findViewHolderForAdapterPosition(0)?.itemView + return first?.requestFocus() ?: true + } + } + KeyEvent.KEYCODE_DPAD_UP -> { + if (focus.isInside(binding.homeGrid) && focus != binding.homeGrid) { + val item = binding.homeGrid.findContainingItemView(focus) ?: return false + val pos = binding.homeGrid.getChildAdapterPosition(item) + val span = (binding.homeGrid.layoutManager as? GridLayoutManager)?.spanCount ?: 1 + if (pos in 0 until span) return focusContinueList() + } + if (focus.isInside(binding.layoutContinueWatching)) { + return binding.btnMouseMode.requestFocus() + } + } + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (focus.isInside(binding.continueList)) { + val item = binding.continueList.findContainingItemView(focus) ?: return false + val pos = binding.continueList.getChildAdapterPosition(item) + val last = (binding.continueList.adapter?.itemCount ?: 0) - 1 + if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT && pos == 0) return true + if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT && pos == last) return true + } + } + } + return false + } + + fun clearBrowserCache(context: Context, webView: WebView, showToast: Boolean = true) { + try { + webView.clearCache(true) + webView.clearHistory() + } catch (_: Exception) { } + try { + WebStorage.getInstance().deleteAllData() + } catch (_: Exception) { } + try { + File(context.cacheDir, "WebView").deleteRecursively() + } catch (_: Exception) { } + try { + context.getExternalFilesDir("uploads")?.deleteRecursively() + File(context.filesDir, "uploads").deleteRecursively() + } catch (_: Exception) { } + if (showToast) { + Toast.makeText(context, context.getString(R.string.cache_cleared), Toast.LENGTH_SHORT).show() + } + } + + fun cleanExpiredUploadFilesAsync(context: Context) { + val extUploads = context.getExternalFilesDir("uploads") + val intUploads = File(context.filesDir, "uploads") + Thread { + try { + val expireBefore = System.currentTimeMillis() - 3L * 24 * 3600 * 1000 + listOfNotNull(extUploads, intUploads).forEach { dir -> + dir.listFiles()?.forEach { f -> + if (f.isFile && f.lastModified() in 1 until expireBefore) { + f.delete() + } + } + } + } catch (_: Exception) { } + }.start() + } + + fun ensureWebViewCacheDirs(context: Context) { + try { + val cacheDir = context.cacheDir ?: return + val base = File(cacheDir, "WebView/Default") + base.mkdirs() + File(base, "HTTP Cache/Code Cache/js").mkdirs() + } catch (_: Exception) { } + } + + fun showContentFilterDialog( + activity: BrowserActivity, + webView: WebView, + isOnHomePage: Boolean, + onFilterChanged: () -> Unit + ) { + val strong = ContentFilterHelper.isStrongFilterEnabled(activity) + val noImage = ContentFilterHelper.isNoImageMode(activity) + val host = if (isOnHomePage) null else webView.url + ?.takeIf { it.startsWith("http", ignoreCase = true) } + ?.let { runCatching { Uri.parse(it).host }.getOrNull() } + val siteWhitelisted = host != null && ContentFilterHelper.isWhitelisted(activity, host) + + val labels = mutableListOf() + val actions = mutableListOf<() -> Unit>() + labels.add("强力过滤可疑动图(当前:${if (strong) "开启" else "关闭"})") + actions.add { + ContentFilterHelper.setStrongFilterEnabled(activity, !strong) + Toast.makeText(activity, if (!strong) "已开启强力过滤" else "已关闭强力过滤", Toast.LENGTH_SHORT).show() + } + if (host != null && strong) { + val domain = ContentFilterHelper.registrableDomain(host) ?: host + labels.add(if (siteWhitelisted) "本站 $domain(当前:不过滤)· 点此恢复过滤" else "本站 $domain(当前:过滤中)· 误伤点此不过滤") + actions.add { + ContentFilterHelper.setWhitelisted(activity, host, !siteWhitelisted) + Toast.makeText(activity, if (siteWhitelisted) "已恢复过滤:$domain" else "本站不再过滤:$domain", Toast.LENGTH_SHORT).show() + } + } + labels.add("无图模式(当前:${if (noImage) "开启" else "关闭"})· 不加载任何网页图片") + actions.add { + ContentFilterHelper.setNoImageMode(activity, !noImage) + webView.settings.blockNetworkImage = !noImage + Toast.makeText(activity, if (!noImage) "已开启无图模式" else "已关闭无图模式", Toast.LENGTH_SHORT).show() + } + val lenientSsl = AppSettings.isLenientSsl(activity) + labels.add("宽松 SSL 证书兼容(当前:${if (lenientSsl) "开启" else "仅日期兼容"})· 解决电视时钟未同步白屏") + actions.add { + AppSettings.setLenientSsl(activity, !lenientSsl) + Toast.makeText( + activity, + if (!lenientSsl) "已开启宽松 SSL 兼容模式" else "已切换为严格 SSL 校验(仅放行电视时钟偏差)", + Toast.LENGTH_SHORT + ).show() + } + + AlertDialog.Builder(activity) + .setTitle(FILTER_MENU_TEXT) + .setItems(labels.toTypedArray()) { _, which -> + actions[which]() + onFilterChanged() + } + .setNegativeButton(activity.getString(R.string.close), null) + .show() + } + + fun showSearchEngineDialog(context: Context, onChanged: (() -> Unit)? = null) { + val engines = SearchEngineHelper.ENGINES + val currentId = SearchEngineHelper.getDefaultEngine(context).id + val names = engines.map { if (it.id == currentId) "● ${it.name} (当前默认)" else " ${it.name}" }.toTypedArray() + AlertDialog.Builder(context) + .setTitle("选择默认搜索引擎") + .setItems(names) { _, which -> + val chosen = engines[which] + SearchEngineHelper.setDefaultEngine(context, chosen.id) + onChanged?.invoke() + Toast.makeText(context, "默认搜索引擎已切换为:${chosen.name}", Toast.LENGTH_SHORT).show() + } + .setNegativeButton("取消", null) + .show() + } + + fun showPlaybackHistoryDialog( + context: Context, + repo: PlaybackHistoryRepository, + continueWatchingView: View + ) { + val records = repo.getAll() + if (records.isEmpty()) { + Toast.makeText(context, "暂无播放历史记录", Toast.LENGTH_SHORT).show() + return + } + val items = records.map { + "${it.title}\n进度:${it.formatPosition()} / ${it.formatDuration()} (${it.getProgressPercent()}%)" + }.toTypedArray() + AlertDialog.Builder(context) + .setTitle("播放历史记录 (${records.size})") + .setItems(items) { _, which -> + val r = records[which] + PlayerActivity.start(context, r.url, r.title) + } + .setPositiveButton("清空历史") { _, _ -> + repo.clearAll() + continueWatchingView.visibility = View.GONE + Toast.makeText(context, "播放历史已清空", Toast.LENGTH_SHORT).show() + } + .setNegativeButton("关闭", null) + .show() + } + + fun showZoomDialog( + context: Context, + currentZoomLevel: Int, + onSelectZoom: (Int) -> Unit + ) { + val labels = arrayOf( + "50%(极小 · 宽屏全览)", + "75%(较小 · 适合桌面版)", + "90%(适度缩小)", + "100%(标准默认)", + "125%(放大 · 推荐)", + "150%(大字清晰)", + "175%(超大模式)", + "200%(特大视界)" + ) + val currentIdx = BrowserActivity.ZOOM_OPTIONS.indexOf(currentZoomLevel).coerceAtLeast(0) + AlertDialog.Builder(context) + .setTitle("网页缩放比例") + .setSingleChoiceItems(labels, currentIdx) { dialog, which -> + val newZoom = BrowserActivity.ZOOM_OPTIONS[which] + onSelectZoom(newZoom) + Toast.makeText(context, "已设置网页缩放为 ${newZoom}%", Toast.LENGTH_SHORT).show() + dialog.dismiss() + } + .setNegativeButton(context.getString(R.string.close), null) + .show() + } + + fun checkNetworkDiagnostics(activity: AppCompatActivity) { + val progress = AlertDialog.Builder(activity) + .setTitle(activity.getString(R.string.check_network)) + .setMessage(activity.getString(R.string.network_checking)) + .setCancelable(false) + .create() + progress.show() + + Thread { + val linkStatus = NetworkChecker.getStatusMessage(activity) + val internetReachable = NetworkChecker.isInternetReachable() + val internetStatus = if (internetReachable) { + activity.getString(R.string.internet_ok) + } else { + activity.getString(R.string.internet_unreachable) + } + val currentYear = Calendar.getInstance().get(Calendar.YEAR) + val timeTip = if (currentYear < 2024) "\n⚠️ 提示:检测到电视系统年份为 ${currentYear} 年,系统时钟可能未同步,浏览器已开启 SSL 兼容放行模式。" else "" + activity.runOnUiThread { + if (!activity.isFinishing && !activity.isDestroyed) { + progress.dismiss() + AlertDialog.Builder(activity) + .setTitle(activity.getString(R.string.check_network)) + .setMessage("$linkStatus\n$internetStatus$timeTip") + .setPositiveButton(activity.getString(R.string.close), null) + .show() + } + } + }.start() + } + + fun showMoreMenu( + activity: BrowserActivity, + sniffedCount: Int, + hasVideo: Boolean, + onVodSearch: () -> Unit, + onPlaybackHistory: () -> Unit, + onLaunchNative: () -> Unit, + onLaunchExternal: () -> Unit + ) { + fun onOff(on: Boolean) = if (on) "开启" else "关闭" + val items = mutableListOf Unit>>() + items.add("⚙️ 系统设置中心(播放 / 浏览 / 安全 / 存储)" to { activity.showSettingsDialog() }) + if (hasVideo) { + val lineCount = sniffedCount.coerceAtLeast(1) + items.add("🎬 原生播放器全屏播放(${lineCount} 条线路)" to onLaunchNative) + items.add("📺 调用外部播放器(MX / Kodi / VLC)" to onLaunchExternal) + } + items.add(VOD_SEARCH_MENU_TEXT to onVodSearch) + items.add("⏱️ 播放历史记录" to onPlaybackHistory) + items.add("自动全屏播放(当前:${onOff(AppSettings.isAutoTakeover(activity))})" to { activity.toggleAutoTakeover() }) + items.add("净化播放页(当前:${onOff(AppSettings.isPurifyPage(activity))})" to { activity.togglePurifyPage() }) + items.add("浏览器标识(当前:${if (activity.isDesktopUa) "电脑桌面版" else "移动手机版"})" to { activity.toggleUserAgent() }) + items.add("清除缓存" to { activity.clearBrowserCache(showToast = true) }) + items.add("关于" to { activity.showSettingsDialog(SettingsDialog.TAB_ABOUT) }) + + AlertDialog.Builder(activity) + .setTitle(activity.getString(R.string.more)) + .setItems(items.map { it.first }.toTypedArray()) { _, which -> items[which].second() } + .setNegativeButton(activity.getString(R.string.close), null) + .show() + } + + fun updateContinueWatchingBanner( + activity: AppCompatActivity, + binding: ActivityBrowserBinding, + repo: PlaybackHistoryRepository, + limit: Int + ) { + val list = binding.continueList + val adapter = list.adapter as? ContinueWatchingAdapter ?: ContinueWatchingAdapter( + onPlay = { record -> PlayerActivity.start(activity, record.url, record.title) }, + onRemove = { record, pos -> + repo.remove(record.url) + Toast.makeText(activity, "已从继续观看移除:${record.displayTitle()}", Toast.LENGTH_SHORT).show() + updateContinueWatchingBanner(activity, binding, repo, limit) + val count = list.adapter?.itemCount ?: 0 + if (count > 0) { + val target = pos.coerceIn(0, count - 1) + list.post { list.findViewHolderForAdapterPosition(target)?.itemView?.requestFocus() } + } else { + binding.btnMouseMode.requestFocus() + } + } + ).also { + list.layoutManager = LinearLayoutManager(activity, LinearLayoutManager.HORIZONTAL, false) + list.itemAnimator = null + list.adapter = it + } + val records = repo.getRecent(limit) + adapter.submit(records) + binding.layoutContinueWatching.visibility = if (records.isNotEmpty()) View.VISIBLE else View.GONE + } + + fun focusContinueList(binding: ActivityBrowserBinding): Boolean { + val list = binding.continueList + val first = list.findViewHolderForAdapterPosition(0)?.itemView ?: list.getChildAt(0) + if (first != null) return first.requestFocus() + if ((list.adapter?.itemCount ?: 0) == 0) return false + list.scrollToPosition(0) + list.post { list.findViewHolderForAdapterPosition(0)?.itemView?.requestFocus() } + return true + } + + fun hideKeyboard(activity: AppCompatActivity) { + val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager ?: return + val token = activity.currentFocus?.windowToken ?: activity.window.decorView.windowToken + imm.hideSoftInputFromWindow(token, 0) + } + + fun showUrlBarKeyboard(activity: AppCompatActivity, urlBar: EditText) { + if (!urlBar.hasFocus()) urlBar.requestFocus() + urlBar.selectAll() + val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager ?: return + if (!imm.showSoftInput(urlBar, 0)) { + urlBar.postDelayed({ if (urlBar.hasFocus()) imm.showSoftInput(urlBar, 0) }, 120) + } + } + + fun setupUrlBarKeys(activity: AppCompatActivity, urlBar: EditText, onGo: () -> Unit) { + urlBar.setOnKeyListener { _, keyCode, event -> + if (event == null) return@setOnKeyListener false + val softKey = (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD) != 0 + val imeVisible = ViewCompat.getRootWindowInsets(urlBar)?.isVisible(WindowInsetsCompat.Type.ime()) == true + when (keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER -> { + if (event.action == KeyEvent.ACTION_UP) showUrlBarKeyboard(activity, urlBar) + true + } + KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { + if (softKey || imeVisible) { + if (event.action == KeyEvent.ACTION_DOWN) onGo() + } else if (event.action == KeyEvent.ACTION_UP) { + showUrlBarKeyboard(activity, urlBar) + } + true + } + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> { + if (softKey || imeVisible) return@setOnKeyListener false + if (event.action == KeyEvent.ACTION_DOWN) { + val dir = if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) View.FOCUS_LEFT else View.FOCUS_RIGHT + urlBar.focusSearch(dir)?.requestFocus(dir) + } + true + } + else -> false + } + } + } + + fun isHijackNavigation( + targetUrl: String, + currentPageHost: String?, + lastUserLink: String?, + lastUserLinkTime: Long + ): Boolean { + val targetDomain = ContentFilterHelper.registrableDomain(runCatching { Uri.parse(targetUrl).host }.getOrNull()) + val pageDomain = ContentFilterHelper.registrableDomain(currentPageHost) + if (targetDomain == null || pageDomain == null || targetDomain == pageDomain) return false + if (!lastUserLink.isNullOrBlank() && SystemClock.uptimeMillis() - lastUserLinkTime < 3000) { + if (lastUserLink == targetUrl) return false + val linkDomain = ContentFilterHelper.registrableDomain(runCatching { Uri.parse(lastUserLink).host }.getOrNull()) + if (linkDomain == targetDomain) return false + } + return true + } +} diff --git a/app/src/main/java/com/tv/browser/ContentFilterHelper.kt b/app/src/main/java/com/tv/browser/ContentFilterHelper.kt index 77bffa8..a909ed2 100644 --- a/app/src/main/java/com/tv/browser/ContentFilterHelper.kt +++ b/app/src/main/java/com/tv/browser/ContentFilterHelper.kt @@ -38,32 +38,51 @@ object ContentFilterHelper { // ---------------- 开关 ---------------- + /** 内存缓存(shouldInterceptRequest 在后台线程高频读取,避免反复触发 SharedPreferences 锁) */ + @Volatile private var cachedStrongFilter: Boolean? = null + @Volatile private var cachedNoImage: Boolean? = null + @Volatile private var cachedWhitelist: Set? = null + /** 强力过滤可疑动图(默认开启) */ - fun isStrongFilterEnabled(context: Context): Boolean = - prefs(context).getBoolean(KEY_STRONG_FILTER, true) + fun isStrongFilterEnabled(context: Context): Boolean { + cachedStrongFilter?.let { return it } + val enabled = prefs(context).getBoolean(KEY_STRONG_FILTER, true) + cachedStrongFilter = enabled + return enabled + } fun setStrongFilterEnabled(context: Context, enabled: Boolean) { + cachedStrongFilter = enabled prefs(context).edit().putBoolean(KEY_STRONG_FILTER, enabled).apply() } /** 无图模式(默认关闭) */ - fun isNoImageMode(context: Context): Boolean = - prefs(context).getBoolean(KEY_NO_IMAGE, false) + fun isNoImageMode(context: Context): Boolean { + cachedNoImage?.let { return it } + val enabled = prefs(context).getBoolean(KEY_NO_IMAGE, false) + cachedNoImage = enabled + return enabled + } fun setNoImageMode(context: Context, enabled: Boolean) { + cachedNoImage = enabled prefs(context).edit().putBoolean(KEY_NO_IMAGE, enabled).apply() } /** 站点是否在白名单(不过滤)中,按主域判断 */ fun isWhitelisted(context: Context, host: String?): Boolean { val domain = registrableDomain(host) ?: return false - return prefs(context).getStringSet(KEY_WHITELIST, emptySet())?.contains(domain) == true + val list = cachedWhitelist ?: prefs(context).getStringSet(KEY_WHITELIST, emptySet()).orEmpty().toSet().also { + cachedWhitelist = it + } + return list.contains(domain) } fun setWhitelisted(context: Context, host: String?, whitelisted: Boolean) { val domain = registrableDomain(host) ?: return - val set = prefs(context).getStringSet(KEY_WHITELIST, emptySet()).orEmpty().toMutableSet() + val set = (cachedWhitelist ?: prefs(context).getStringSet(KEY_WHITELIST, emptySet()).orEmpty()).toMutableSet() if (whitelisted) set.add(domain) else set.remove(domain) + cachedWhitelist = set.toSet() prefs(context).edit().putStringSet(KEY_WHITELIST, set).apply() } diff --git a/app/src/main/java/com/tv/browser/CursorController.kt b/app/src/main/java/com/tv/browser/CursorController.kt new file mode 100644 index 0000000..f31d70d --- /dev/null +++ b/app/src/main/java/com/tv/browser/CursorController.kt @@ -0,0 +1,233 @@ +package com.tv.browser + +import android.content.Context +import android.os.SystemClock +import android.util.TypedValue +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.webkit.WebView +import android.widget.ImageView +import androidx.fragment.app.Fragment +import java.util.Locale + +/** + * 模拟鼠标光标控制器(从 BrowserActivity 拆分): + * 负责遥控器 D-pad 模拟鼠标位置计算、边界收紧、边缘滚动、内部滚动区滚动、光标处点击派发与显隐状态管理。 + */ +class CursorController( + private val context: Context, + private val cursorView: ImageView?, + private val cursorOverlay: ViewGroup?, + private val contentLayout: ViewGroup?, + private val webView: WebView, + private val toolbarScroll: View, + private val toolbar: View, + private val webContainer: View +) { + var cursorX: Float = 0f + private set + var cursorY: Float = 0f + private set + private var cursorStepPx: Int = 9 + private val edgeScrollPx: Int + get() = (48 * context.resources.displayMetrics.density).toInt() + + /** 光标隐藏原因集合(弹窗、网页全屏视频等),按来源对象去重,幂等防泄漏 */ + private val cursorHideTokens = LinkedHashSet() + val customViewCursorToken = Any() + + init { + cursorView?.isFocusable = false + cursorView?.isClickable = false + // FrameLayout(clickable=false) 默认将触摸事件穿透给下层 contentLayout + val passThrough = View.OnTouchListener { _, _ -> false } + cursorView?.setOnTouchListener(passThrough) + cursorOverlay?.setOnTouchListener(passThrough) + cursorOverlay?.post { initPosition() } + } + + /** 初始化模拟鼠标箭头位置为全屏中心(含工具栏区域) */ + fun initPosition() { + val overlay = cursorOverlay ?: return + val cursor = cursorView ?: return + cursorStepPx = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 9f, context.resources.displayMetrics + ).toInt() + val cw = overlay.width + val ch = overlay.height + val cwv = cursor.width + val chv = cursor.height + if (cw <= 0 || ch <= 0) return + cursorX = ((cw - cwv) / 2f).coerceIn(0f, (cw - cwv).toFloat()) + cursorY = ((ch - chv) / 2f).coerceIn(0f, (ch - chv).toFloat()) + cursor.x = cursorX + cursor.y = cursorY + } + + /** 按步长移动光标并刷新位置;仅当有垂直移动(dy!=0)时触发边缘滚动 */ + fun move(dx: Int, dy: Int, isOnHomePage: Boolean, onReachTopEdge: () -> Unit) { + val overlay = cursorOverlay ?: return + val cursor = cursorView ?: return + val ow = overlay.width + val oh = overlay.height + val cw = cursor.width + val ch = cursor.height + val maxX = (ow - cw).toFloat().coerceAtLeast(0f) + val maxY = (oh - ch).toFloat().coerceAtLeast(0f) + cursorX = (cursorX + dx).coerceIn(0f, maxX) + cursorY = (cursorY + dy).coerceIn(0f, maxY) + cursor.x = cursorX + cursor.y = cursorY + if (dy != 0) { + performEdgeScrollIfNeeded(dy, isOnHomePage) + if (!isOnHomePage && cursorY < 64 * context.resources.displayMetrics.density) { + onReachTopEdge() + } + } + } + + /** 将光标限制在全屏 overlay 内(如全屏切换后) */ + fun clampToBounds() { + val overlay = cursorOverlay ?: return + val cursor = cursorView ?: return + if (overlay.width <= 0 || overlay.height <= 0) return + val maxX = (overlay.width - cursor.width).toFloat().coerceAtLeast(0f) + val maxY = (overlay.height - cursor.height).toFloat().coerceAtLeast(0f) + val newX = cursorX.coerceIn(0f, maxX) + val newY = cursorY.coerceIn(0f, maxY) + if (newX != cursorX || newY != cursorY) { + cursorX = newX + cursorY = newY + cursor.x = cursorX + cursor.y = cursorY + } + } + + /** 在光标位置向整页(contentLayout)派发一次点击,可点到工具栏或 WebView */ + fun dispatchClick() { + val content = contentLayout ?: return + val now = SystemClock.uptimeMillis() + val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, cursorX, cursorY, 0) + content.dispatchTouchEvent(down) + down.recycle() + val up = MotionEvent.obtain(now, now + 50, MotionEvent.ACTION_UP, cursorX, cursorY, 0) + content.dispatchTouchEvent(up) + up.recycle() + } + + /** 焦点是否在网页内容区且应使用模拟鼠标(首页卡片或工具栏按钮走正常 D-pad 焦点流转) */ + fun isFocusInContent(focus: View?, isOnHomePage: Boolean): Boolean { + if (isOnHomePage) return false + if (focus == null) return true + var v: View? = focus + while (v != null) { + if (v == toolbar) return false + if (v == webContainer || v == webView) return true + v = v.parent as? View + } + return false + } + + /** 处理网页内容区的 D-pad 模拟鼠标移动与点击 */ + fun handleKeyEvent( + event: KeyEvent, + isOnHomePage: Boolean, + isMouseMode: Boolean, + currentFocus: View?, + onReachTopEdge: () -> Unit + ): Boolean { + if (!isMouseMode || !isFocusInContent(currentFocus, isOnHomePage)) return false + val keyCode = event.keyCode + val action = event.action + if (action == KeyEvent.ACTION_DOWN) { + val repeat = event.repeatCount + val accelStep = (cursorStepPx * (1f + (repeat * 0.35f).coerceAtMost(3.0f))).toInt() + when (keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> { move(-accelStep, 0, isOnHomePage, onReachTopEdge); return true } + KeyEvent.KEYCODE_DPAD_RIGHT -> { move(accelStep, 0, isOnHomePage, onReachTopEdge); return true } + KeyEvent.KEYCODE_DPAD_UP -> { move(0, -accelStep, isOnHomePage, onReachTopEdge); return true } + KeyEvent.KEYCODE_DPAD_DOWN -> { move(0, accelStep, isOnHomePage, onReachTopEdge); return true } + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> return true + } + } else if (action == KeyEvent.ACTION_UP) { + if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) { + dispatchClick() + return true + } + if (keyCode in listOf( + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN + ) + ) { + return true + } + } + return false + } + + fun setVisible( + visible: Boolean, + token: Any, + isOnHomePage: Boolean, + isMouseMode: Boolean, + isCustomViewFullscreen: Boolean + ) { + if (visible) cursorHideTokens.remove(token) else cursorHideTokens.add(token) + updateVisibility(isOnHomePage, isMouseMode, isCustomViewFullscreen) + } + + fun updateVisibility( + isOnHomePage: Boolean, + isMouseMode: Boolean, + isCustomViewFullscreen: Boolean + ) { + cursorHideTokens.removeAll { it is Fragment && !it.isAdded } + if (!isCustomViewFullscreen) cursorHideTokens.remove(customViewCursorToken) + val shouldShow = !isOnHomePage && isMouseMode && cursorHideTokens.isEmpty() + cursorOverlay?.visibility = if (shouldShow) View.VISIBLE else View.GONE + } + + private fun performEdgeScrollIfNeeded(dy: Int, isOnHomePage: Boolean) { + if (isOnHomePage) return + val th = if (toolbarScroll.visibility == View.VISIBLE) toolbarScroll.height else 0 + val wh = webContainer.height + if (wh <= 0) return + val webTop = th.toFloat() + val webBottom = th + wh + val dir = when { + dy < 0 && cursorY < webTop + edgeScrollPx -> -1 + dy > 0 && cursorY > webBottom - edgeScrollPx -> 1 + else -> return + } + if (webView.canScrollVertically(dir)) { + webView.scrollBy(0, dir * edgeScrollPx) + } else { + scrollInnerAtCursor(dir) + } + } + + private fun scrollInnerAtCursor(dir: Int) { + val overlay = cursorOverlay ?: return + val ww = webView.width + val wh = webView.height + if (ww <= 0 || wh <= 0) return + val wl = IntArray(2).also { webView.getLocationOnScreen(it) } + val ol = IntArray(2).also { overlay.getLocationOnScreen(it) } + val fx = ((cursorX + ol[0] - wl[0]) / ww).coerceIn(0f, 0.999f) + val fy = ((cursorY + ol[1] - wl[1]) / wh).coerceIn(0f, 0.999f) + val fdy = dir * edgeScrollPx.toFloat() / wh + val js = String.format( + Locale.US, + "(function(fx,fy,fdy){try{var d=fdy*innerHeight,n=document.elementFromPoint(fx*innerWidth,fy*innerHeight);" + + "while(n&&n.nodeType===1&&n!==document.body&&n!==document.documentElement){" + + "var s=getComputedStyle(n);if(/(auto|scroll|overlay)/.test(s.overflowY)&&n.scrollHeight>n.clientHeight+1){" + + "var b=n.scrollTop;n.scrollTop=b+d;if(n.scrollTop!==b)return;}n=n.parentElement;}}catch(e){}})(%.4f,%.4f,%.4f);", + fx, fy, fdy + ) + webView.evaluateJavascript(js, null) + } +} diff --git a/app/src/main/java/com/tv/browser/CustomViewFullscreenHelper.kt b/app/src/main/java/com/tv/browser/CustomViewFullscreenHelper.kt new file mode 100644 index 0000000..86af0be --- /dev/null +++ b/app/src/main/java/com/tv/browser/CustomViewFullscreenHelper.kt @@ -0,0 +1,212 @@ +package com.tv.browser + +import android.graphics.Color +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.webkit.WebChromeClient +import android.webkit.WebView +import android.widget.FrameLayout +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity + +/** + * WebView 网页视频全屏管理组件(从 BrowserActivity 拆分): + * 负责 WebView CustomView 原生全屏容器、系统沉浸式全屏切换以及应用级全屏降级。 + */ +class CustomViewFullscreenHelper( + private val activity: AppCompatActivity, + private val webView: WebView, + private val toolbarScroll: View, + private val cursorController: CursorController, + private val isOnHomePageProvider: () -> Boolean, + private val isMouseModeProvider: () -> Boolean, + private val runVideoJs: (String, (String) -> Unit) -> Unit +) { + private val mainHandler = Handler(Looper.getMainLooper()) + var fullscreenContainer: FrameLayout? = null + private set + private var customViewCallback: WebChromeClient.CustomViewCallback? = null + var isAppFullscreen: Boolean = false + private set + + val isCustomViewActive: Boolean + get() = fullscreenContainer != null && fullscreenContainer?.parent != null + + val isAnyFullscreen: Boolean + get() = isCustomViewActive || isAppFullscreen + + /** 进入全屏 CustomView 处理:支持机型差异、沉浸模式并隐藏工具栏与光标 */ + fun showCustomView(view: View?, callback: WebChromeClient.CustomViewCallback?) { + if (view == null || callback == null) return + exitCustomView() + + toolbarScroll.visibility = View.GONE + setImmersiveFullscreen(true) + + val container = FrameLayout(activity).apply { + setBackgroundColor(Color.BLACK) + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + container.addView( + view, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + activity.findViewById(android.R.id.content).addView(container) + fullscreenContainer = container + customViewCallback = callback + + cursorController.setVisible( + visible = false, + token = cursorController.customViewCursorToken, + isOnHomePage = isOnHomePageProvider(), + isMouseMode = isMouseModeProvider(), + isCustomViewFullscreen = true + ) + Toast.makeText(activity, "全屏播放:确认键暂停,左右键快进快退,返回键退出全屏", Toast.LENGTH_SHORT).show() + } + + /** 退出 WebView 全屏视频(CustomView),并通知 WebView */ + fun exitCustomView() { + val wasFullscreen = fullscreenContainer != null + fullscreenContainer?.let { container -> + (container.parent as? ViewGroup)?.removeView(container) + container.removeAllViews() + } + fullscreenContainer = null + customViewCallback?.onCustomViewHidden() + customViewCallback = null + + if (wasFullscreen && !isAppFullscreen) { + toolbarScroll.visibility = View.VISIBLE + setImmersiveFullscreen(false) + } + cursorController.setVisible( + visible = true, + token = cursorController.customViewCursorToken, + isOnHomePage = isOnHomePageProvider(), + isMouseMode = isMouseModeProvider(), + isCustomViewFullscreen = false + ) + if (wasFullscreen) { + toolbarScroll.post { cursorController.clampToBounds() } + webView.requestFocus() + } + } + + /** 进入应用级全屏:隐藏工具栏,让 WebView 占满屏幕(网页全屏或机型全屏降级) */ + fun enterAppFullscreen() { + if (isAppFullscreen) return + isAppFullscreen = true + toolbarScroll.visibility = View.GONE + setImmersiveFullscreen(true) + toolbarScroll.post { cursorController.clampToBounds() } + Toast.makeText(activity, activity.getString(R.string.video_fullscreen_app_mode), Toast.LENGTH_SHORT).show() + } + + /** 退出应用级全屏:恢复工具栏 */ + fun exitAppFullscreen() { + if (!isAppFullscreen) return + isAppFullscreen = false + toolbarScroll.visibility = View.VISIBLE + setImmersiveFullscreen(false) + toolbarScroll.post { cursorController.clampToBounds() } + } + + fun toggleAppFullscreen() { + if (isAppFullscreen) exitAppFullscreen() else enterAppFullscreen() + } + + /** 触发页面内视频全屏:先 requestFullscreen,再尝试双击;均未弹出则 1s 后降级为应用级全屏 */ + fun triggerVideoFullscreen() { + Log.d(TAG, "triggerVideoFullscreen() called") + runVideoJs(VideoControlHelper.JS_FULLSCREEN) { r -> + if (r != "ok") { + runVideoJs(VideoControlHelper.JS_FULLSCREEN_DBLCLICK) { } + } + } + mainHandler.postDelayed({ + if (fullscreenContainer == null && !isAppFullscreen) { + toggleAppFullscreen() + } + }, 1000) + } + + /** 全屏视频(CustomView)时的遥控器方向键/确认键控制 */ + fun handleKeyEvent(event: KeyEvent): Boolean { + if (event.action != KeyEvent.ACTION_DOWN || !isCustomViewActive) return false + when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> + runVideoJs(VideoControlHelper.JS_TOGGLE_PLAY) { } + KeyEvent.KEYCODE_DPAD_LEFT -> + runVideoJs(VideoControlHelper.JS_SEEK_BACK) { } + KeyEvent.KEYCODE_DPAD_RIGHT -> + runVideoJs(VideoControlHelper.JS_SEEK_FORWARD) { } + KeyEvent.KEYCODE_DPAD_UP -> + runVideoJs(VideoControlHelper.JS_VOLUME_UP) { } + KeyEvent.KEYCODE_DPAD_DOWN -> + runVideoJs(VideoControlHelper.JS_VOLUME_DOWN) { } + else -> return false + } + return true + } + + /** 手机遥控点击:若正处于 CustomView 全屏则在全屏容器中心点击 */ + fun handleRemoteClick(): Boolean { + if (!isCustomViewActive) return false + try { + val container = fullscreenContainer!! + val target = if (container.childCount > 0) container.getChildAt(0) else container + val w = target.width + val h = target.height + if (w > 0 && h > 0) { + val now = SystemClock.uptimeMillis() + val x = w / 2f + val y = h / 2f + val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0) + val up = MotionEvent.obtain(now, now + 50, MotionEvent.ACTION_UP, x, y, 0) + target.dispatchTouchEvent(down) + target.dispatchTouchEvent(up) + down.recycle() + up.recycle() + return true + } + } catch (_: Exception) { } + runVideoJs(VideoControlHelper.JS_TOGGLE_PLAY) { } + return true + } + + @Suppress("DEPRECATION") + private fun setImmersiveFullscreen(enable: Boolean) { + if (enable) { + activity.window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + activity.window.decorView.systemUiVisibility = ( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_FULLSCREEN + or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + ) + } else { + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE + } + } + + companion object { + private const val TAG = "TVBrowser.Fullscreen" + } +} diff --git a/app/src/main/java/com/tv/browser/HistoryRepository.kt b/app/src/main/java/com/tv/browser/HistoryRepository.kt index e18a587..d676ff0 100644 --- a/app/src/main/java/com/tv/browser/HistoryRepository.kt +++ b/app/src/main/java/com/tv/browser/HistoryRepository.kt @@ -6,7 +6,7 @@ import org.json.JSONArray import org.json.JSONObject /** - * 浏览历史持久化,最多保留 [MAX_ENTRIES] 条。 + * 浏览历史持久化(内存缓存 + SharedPreferences 异步落盘),最多保留 [MAX_ENTRIES] 条。 */ class HistoryRepository(context: Context) { @@ -14,20 +14,29 @@ class HistoryRepository(context: Context) { private val keyHistory = "history" fun getAll(): List { - val json = prefs.getString(keyHistory, null) - if (json.isNullOrBlank()) return emptyList() - return try { - val arr = JSONArray(json) - List(arr.length()) { i -> - val obj = arr.getJSONObject(i) - HistoryItem( - url = obj.getString(KEY_URL), - title = obj.optString(KEY_TITLE, ""), - visitedAt = obj.optLong(KEY_VISITED_AT, 0L) - ) + cachedHistory?.let { return it } + synchronized(Companion) { + cachedHistory?.let { return it } + val json = prefs.getString(keyHistory, null) + val loaded = if (json.isNullOrBlank()) { + emptyList() + } else { + try { + val arr = JSONArray(json) + List(arr.length()) { i -> + val obj = arr.getJSONObject(i) + HistoryItem( + url = obj.getString(KEY_URL), + title = obj.optString(KEY_TITLE, ""), + visitedAt = obj.optLong(KEY_VISITED_AT, 0L) + ) + } + } catch (_: Exception) { + emptyList() + } } - } catch (_: Exception) { - emptyList() + cachedHistory = loaded + return loaded } } @@ -42,19 +51,26 @@ class HistoryRepository(context: Context) { } fun clear() { - prefs.edit().remove(keyHistory).apply() + synchronized(Companion) { + cachedHistory = emptyList() + prefs.edit().remove(keyHistory).apply() + } } private fun save(list: List) { - val arr = JSONArray() - list.forEach { item -> - arr.put(JSONObject().apply { - put(KEY_URL, item.url) - put(KEY_TITLE, item.title) - put(KEY_VISITED_AT, item.visitedAt) - }) + val snapshot = list.toList() + synchronized(Companion) { + cachedHistory = snapshot + val arr = JSONArray() + snapshot.forEach { item -> + arr.put(JSONObject().apply { + put(KEY_URL, item.url) + put(KEY_TITLE, item.title) + put(KEY_VISITED_AT, item.visitedAt) + }) + } + prefs.edit().putString(keyHistory, arr.toString()).apply() } - prefs.edit().putString(keyHistory, arr.toString()).apply() } companion object { @@ -63,5 +79,7 @@ class HistoryRepository(context: Context) { private const val KEY_TITLE = "title" private const val KEY_VISITED_AT = "visitedAt" const val MAX_ENTRIES = 50 + @Volatile + private var cachedHistory: List? = null } } diff --git a/app/src/main/java/com/tv/browser/PlaybackHistoryRepository.kt b/app/src/main/java/com/tv/browser/PlaybackHistoryRepository.kt index 1719dd0..e81c448 100644 --- a/app/src/main/java/com/tv/browser/PlaybackHistoryRepository.kt +++ b/app/src/main/java/com/tv/browser/PlaybackHistoryRepository.kt @@ -58,31 +58,41 @@ data class PlaybackRecord( } /** - * 播放历史持久化管理(SharedPreferences + JSON) + * 播放历史持久化管理(内存缓存 + SharedPreferences 异步落盘,避免播放中高频重复解析 JSON) */ class PlaybackHistoryRepository(context: Context) { private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) fun getAll(): List { - val json = prefs.getString(KEY_RECORDS, null) ?: return emptyList() - return try { - val arr = JSONArray(json) - List(arr.length()) { i -> - val obj = arr.getJSONObject(i) - PlaybackRecord( - url = obj.optString("url", ""), - title = obj.optString("title", ""), - positionMs = obj.optInt("positionMs", 0), - durationMs = obj.optInt("durationMs", 0), - timestamp = obj.optLong("timestamp", 0), - series = obj.optString("series", ""), - seriesTitle = obj.optString("seriesTitle", ""), - episodeName = obj.optString("episodeName", "") - ) - }.filter { it.url.isNotBlank() } - } catch (_: Exception) { - emptyList() + cachedRecords?.let { return it } + synchronized(Companion) { + cachedRecords?.let { return it } + val json = prefs.getString(KEY_RECORDS, null) + val loaded = if (json.isNullOrBlank()) { + emptyList() + } else { + try { + val arr = JSONArray(json) + List(arr.length()) { i -> + val obj = arr.getJSONObject(i) + PlaybackRecord( + url = obj.optString("url", ""), + title = obj.optString("title", ""), + positionMs = obj.optInt("positionMs", 0), + durationMs = obj.optInt("durationMs", 0), + timestamp = obj.optLong("timestamp", 0), + series = obj.optString("series", ""), + seriesTitle = obj.optString("seriesTitle", ""), + episodeName = obj.optString("episodeName", "") + ) + }.filter { it.url.isNotBlank() } + } catch (_: Exception) { + emptyList() + } + } + cachedRecords = loaded + return loaded } } @@ -161,29 +171,38 @@ class PlaybackHistoryRepository(context: Context) { } fun clearAll() { - prefs.edit().remove(KEY_RECORDS).apply() + synchronized(Companion) { + cachedRecords = emptyList() + prefs.edit().remove(KEY_RECORDS).apply() + } } private fun save(list: List) { - val arr = JSONArray() - list.forEach { r -> - arr.put(JSONObject().apply { - put("url", r.url) - put("title", r.title) - put("positionMs", r.positionMs) - put("durationMs", r.durationMs) - put("timestamp", r.timestamp) - if (r.series.isNotBlank()) put("series", r.series) - if (r.seriesTitle.isNotBlank()) put("seriesTitle", r.seriesTitle) - if (r.episodeName.isNotBlank()) put("episodeName", r.episodeName) - }) + val snapshot = list.toList() + synchronized(Companion) { + cachedRecords = snapshot + val arr = JSONArray() + snapshot.forEach { r -> + arr.put(JSONObject().apply { + put("url", r.url) + put("title", r.title) + put("positionMs", r.positionMs) + put("durationMs", r.durationMs) + put("timestamp", r.timestamp) + if (r.series.isNotBlank()) put("series", r.series) + if (r.seriesTitle.isNotBlank()) put("seriesTitle", r.seriesTitle) + if (r.episodeName.isNotBlank()) put("episodeName", r.episodeName) + }) + } + prefs.edit().putString(KEY_RECORDS, arr.toString()).apply() } - prefs.edit().putString(KEY_RECORDS, arr.toString()).apply() } companion object { private const val PREFS_NAME = "tv_browser_playback_history" private const val KEY_RECORDS = "records" private const val MAX_RECORDS = 30 + @Volatile + private var cachedRecords: List? = null } } diff --git a/app/src/main/java/com/tv/browser/PlayerActivity.kt b/app/src/main/java/com/tv/browser/PlayerActivity.kt index 7f1822b..eec49b1 100644 --- a/app/src/main/java/com/tv/browser/PlayerActivity.kt +++ b/app/src/main/java/com/tv/browser/PlayerActivity.kt @@ -139,6 +139,9 @@ class PlayerActivity : AppCompatActivity() { setContentView(binding.root) historyRepo = PlaybackHistoryRepository(this) + val savedSpeed = AppSettings.getPlaybackSpeed(this) + val savedSpeedIdx = speeds.indexOfFirst { kotlin.math.abs(it - savedSpeed) < 0.01f } + if (savedSpeedIdx >= 0) currentSpeedIndex = savedSpeedIdx val rawUrl = intent.getStringExtra(EXTRA_VIDEO_URL).orEmpty() val pageUrlExtra = intent.getStringExtra(EXTRA_PAGE_URL)?.takeIf { it.isNotBlank() } @@ -300,6 +303,7 @@ class PlayerActivity : AppCompatActivity() { private fun initViews() { binding.tvVideoTitle.text = videoTitle + binding.btnSpeed.text = "${speeds[currentSpeedIndex]}x" binding.btnBack.setOnClickListener { finish() } binding.btnExternal.setOnClickListener { launchExternalPlayer() } @@ -1024,6 +1028,7 @@ class PlayerActivity : AppCompatActivity() { private fun togglePlaybackSpeed() { currentSpeedIndex = (currentSpeedIndex + 1) % speeds.size val targetSpeed = speeds[currentSpeedIndex] + AppSettings.setPlaybackSpeed(this, targetSpeed) player?.setPlaybackSpeed(targetSpeed) binding.btnSpeed.text = "${targetSpeed}x" showFeedback("倍速:${targetSpeed}x") @@ -1407,14 +1412,35 @@ class PlayerActivity : AppCompatActivity() { /** 触摸选集面板外部区域时直接收起选集面板,并消费本次手势避免误触底层控制栏 */ private var dismissingEpisodePanelTouch = false + /** 触屏手势控制(手机端):左右滑动调节进度、左侧上下滑动调节亮度、右侧上下滑动调节音量 */ + private var touchGestureEligible = false + private var touchGestureMode = 0 // 0=未触发, 1=水平进度, 2=左侧亮度, 3=右侧音量 + private var touchDownX = 0f + private var touchDownY = 0f + private var gestureStartPos = 0L + private var gestureTargetPos = 0L + private var gestureStartBrightness = 0.5f + private var gestureStartVolume = 0 + override fun dispatchTouchEvent(ev: android.view.MotionEvent): Boolean { if (ev.actionMasked == android.view.MotionEvent.ACTION_DOWN) { if (isEpisodePanelVisible() && !isTouchInsideView(ev.rawX, ev.rawY, binding.episodePanel)) { hideEpisodePanel() dismissingEpisodePanelTouch = true + touchGestureEligible = false return true } dismissingEpisodePanelTouch = false + touchGestureMode = 0 + touchDownX = ev.rawX + touchDownY = ev.rawY + // 仅在非选集面板、且未点在顶部/底部控制栏或暂停浮层上时允许滑动手势 + val onOsdBars = isOsdVisible() && ( + isTouchInsideView(ev.rawX, ev.rawY, binding.topBar) || + isTouchInsideView(ev.rawX, ev.rawY, binding.bottomBar) + ) + val onPauseOverlay = isTouchInsideView(ev.rawX, ev.rawY, binding.pauseOverlay) + touchGestureEligible = !isEpisodePanelVisible() && !onOsdBars && !onPauseOverlay } else if (dismissingEpisodePanelTouch) { if (ev.actionMasked == android.view.MotionEvent.ACTION_UP || ev.actionMasked == android.view.MotionEvent.ACTION_CANCEL @@ -1422,6 +1448,90 @@ class PlayerActivity : AppCompatActivity() { dismissingEpisodePanelTouch = false } return true + } else if (touchGestureEligible) { + when (ev.actionMasked) { + android.view.MotionEvent.ACTION_POINTER_DOWN -> { + touchGestureEligible = false + touchGestureMode = 0 + } + android.view.MotionEvent.ACTION_MOVE -> { + val dx = ev.rawX - touchDownX + val dy = ev.rawY - touchDownY + val slop = android.view.ViewConfiguration.get(this).scaledTouchSlop * 1.5f + if (touchGestureMode == 0 && (kotlin.math.abs(dx) > slop || kotlin.math.abs(dy) > slop)) { + val screenW = resources.displayMetrics.widthPixels.coerceAtLeast(1) + if (kotlin.math.abs(dx) > kotlin.math.abs(dy) * 1.2f) { + touchGestureMode = 1 + gestureStartPos = player?.currentPosition?.coerceAtLeast(0L) ?: 0L + gestureTargetPos = gestureStartPos + isSeeking = true + } else if (kotlin.math.abs(dy) > kotlin.math.abs(dx) * 1.2f) { + if (touchDownX < screenW / 2f) { + touchGestureMode = 2 + val curB = window.attributes.screenBrightness + gestureStartBrightness = if (curB in 0.01f..1.0f) curB else 0.5f + } else { + touchGestureMode = 3 + val am = getSystemService(AUDIO_SERVICE) as? android.media.AudioManager + gestureStartVolume = am?.getStreamVolume(android.media.AudioManager.STREAM_MUSIC) ?: 0 + } + } + } + when (touchGestureMode) { + 1 -> { + val p = player + if (p != null) { + val screenW = resources.displayMetrics.widthPixels.coerceAtLeast(1).toFloat() + val offsetMs = ((dx / screenW) * 180_000f).toLong() + val dur = p.duration + val max = if (dur != C.TIME_UNSET && dur > 0) dur else Long.MAX_VALUE + gestureTargetPos = (gestureStartPos + offsetMs).coerceIn(0L, max) + val realOffsetSec = (gestureTargetPos - gestureStartPos) / 1000 + val sign = if (realOffsetSec >= 0) "+" else "" + showFeedback("${sign}${realOffsetSec}s (${formatTime(gestureTargetPos)})") + } + return true + } + 2 -> { + val screenH = resources.displayMetrics.heightPixels.coerceAtLeast(1).toFloat() + val delta = -dy / (screenH * 0.75f) + val targetB = (gestureStartBrightness + delta).coerceIn(0.05f, 1.0f) + val lp = window.attributes + lp.screenBrightness = targetB + window.attributes = lp + showFeedback("亮度:${(targetB * 100).toInt()}%") + return true + } + 3 -> { + val am = getSystemService(AUDIO_SERVICE) as? android.media.AudioManager + if (am != null) { + val maxVol = am.getStreamMaxVolume(android.media.AudioManager.STREAM_MUSIC).coerceAtLeast(1) + val screenH = resources.displayMetrics.heightPixels.coerceAtLeast(1).toFloat() + val deltaVol = ((-dy / (screenH * 0.75f)) * maxVol).toInt() + val targetVol = (gestureStartVolume + deltaVol).coerceIn(0, maxVol) + am.setStreamVolume(android.media.AudioManager.STREAM_MUSIC, targetVol, 0) + showFeedback("音量:${targetVol * 100 / maxVol}%") + } + return true + } + } + } + android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> { + val mode = touchGestureMode + touchGestureMode = 0 + touchGestureEligible = false + if (mode == 1) { + isSeeking = false + if (ev.actionMasked == android.view.MotionEvent.ACTION_UP) { + player?.seekTo(gestureTargetPos) + updateProgress() + } + return true + } else if (mode == 2 || mode == 3) { + return true + } + } + } } return super.dispatchTouchEvent(ev) } diff --git a/app/src/main/java/com/tv/browser/RemoteControlHelper.kt b/app/src/main/java/com/tv/browser/RemoteControlHelper.kt new file mode 100644 index 0000000..9b9200d --- /dev/null +++ b/app/src/main/java/com/tv/browser/RemoteControlHelper.kt @@ -0,0 +1,204 @@ +package com.tv.browser + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.media.AudioManager +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.util.Log +import android.view.View +import android.webkit.WebView +import android.widget.EditText +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.FileProvider +import java.io.File + +/** + * 局域网手机遥控与文件快传辅助组件(从 BrowserActivity 拆分): + * 负责 RemoteControlServer 启停、状态圆点刷新、远程输入法文字注入、快传 APK 安装与视频播放弹窗。 + */ +class RemoteControlHelper( + private val activity: AppCompatActivity, + private val mainHandler: Handler, + private val callback: RemoteControlCallback +) { + private var server: RemoteControlServer? = null + + fun ensureStarted(): Boolean { + val s = server + if (s != null && s.isServerRunning()) return true + try { + server?.stop() + } catch (_: Exception) { } + server = RemoteControlServer(activity, mainHandler, callback).apply { start() } + return server?.isServerRunning() == true + } + + fun isRunning(): Boolean = server?.isServerRunning() == true + + fun getPort(): Int = server?.port ?: 8766 + + fun restart(): Boolean { + stop() + return ensureStarted() + } + + fun stop() { + try { + server?.stop() + } catch (_: Exception) { } + server = null + } + + fun initStatusDot(dot: View) { + dot.background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(Color.RED) + } + } + + fun updateStatusDot(dot: View) { + val running = isRunning() + (dot.background as? GradientDrawable)?.setColor( + if (running) Color.parseColor("#4CAF50") else Color.RED + ) + } + + fun adjustVolume(raise: Boolean) { + val am = activity.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return + val dir = if (raise) AudioManager.ADJUST_RAISE else AudioManager.ADJUST_LOWER + am.adjustStreamVolume(AudioManager.STREAM_MUSIC, dir, AudioManager.FLAG_SHOW_UI) + } + + fun toggleMute() { + val am = activity.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + am.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_TOGGLE_MUTE, AudioManager.FLAG_SHOW_UI) + } else { + @Suppress("DEPRECATION") + am.setStreamMute(AudioManager.STREAM_MUSIC, true) + } + } + + /** 将手机遥控端发来的文字填入当前焦点输入框、网页 activeElement 或地址栏 */ + fun handleRemoteText( + text: String, + currentFocus: View?, + isOnHomePage: Boolean, + webView: WebView, + urlBar: EditText + ) { + if (text.isEmpty()) return + val focusedEdit = currentFocus as? EditText + if (focusedEdit != null) { + val editable = focusedEdit.text + editable.replace(0, editable.length, text) + focusedEdit.requestFocus() + focusedEdit.setSelection(text.length) + return + } + if (!isOnHomePage) { + val jsText = text + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", "\\n") + .replace("\r", "") + val js = """ + (function() { + var el = document.activeElement; + if (!el || (!el.isContentEditable && ['INPUT','TEXTAREA'].indexOf(el.tagName) === -1)) { + var candidates = Array.prototype.slice.call( + document.querySelectorAll('input, textarea, [contenteditable=""], [contenteditable="true"]') + ); + el = candidates.find(function(f) { + return !f.disabled && f.type !== 'hidden' && f.offsetParent !== null; + }); + } + if (!el) return; + var insert = '$jsText'; + if (typeof el.value === 'string') el.value = insert; + else el.textContent = insert; + var caret = insert.length; + if (el.setSelectionRange) { + try { el.setSelectionRange(caret, caret); } catch (e) {} + } + if (typeof el.focus === 'function') el.focus(); + try { + var evInput = new Event('input', { bubbles: true }); + el.dispatchEvent(evInput); + } catch (e) { + try { + var ev = document.createEvent('Event'); + ev.initEvent('input', true, true); + el.dispatchEvent(ev); + } catch (e2) {} + } + })(); + """.trimIndent() + webView.evaluateJavascript(js, null) + return + } + val editable = urlBar.text + editable.replace(0, editable.length, text) + urlBar.requestFocus() + urlBar.setSelection(text.length) + } + + /** 处理手机快传上传的文件(APK 安装包、视频文件或普通文件) */ + fun handleFileUploaded(file: File, filename: String) { + val lower = filename.lowercase() + if (lower.endsWith(".apk")) { + promptInstallApk(file, filename) + } else if (lower.endsWith(".mp4") || lower.endsWith(".m3u8") || lower.endsWith(".mkv") || lower.endsWith(".flv")) { + AlertDialog.Builder(activity) + .setTitle("🎬 收到手机快传视频") + .setMessage("文件:$filename\n大小:${file.length() / 1024 / 1024}MB\n是否立即全屏播放?") + .setPositiveButton("立即播放") { _, _ -> + PlayerActivity.start(activity, Uri.fromFile(file).toString(), filename) + } + .setNegativeButton("稍后", null) + .show() + } else { + Toast.makeText(activity, "收到手机文件:$filename (已存入下载目录)", Toast.LENGTH_LONG).show() + } + } + + private fun promptInstallApk(file: File, filename: String) { + try { + val uri = FileProvider.getUriForFile( + activity, + "${activity.packageName}.fileprovider", + file + ) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + AlertDialog.Builder(activity) + .setTitle("📱 收到手机快传 APK 安装包") + .setMessage("应用文件:$filename\n大小:${file.length() / 1024 / 1024}MB\n是否立即安装?") + .setPositiveButton("立即安装") { _, _ -> + try { + activity.startActivity(intent) + } catch (e: Exception) { + Log.e(TAG, "Failed to launch package installer", e) + Toast.makeText(activity, "打开安装器失败: ${e.message}", Toast.LENGTH_LONG).show() + } + } + .setNegativeButton("稍后", null) + .show() + } catch (e: Exception) { + Log.e(TAG, "FileProvider error for APK", e) + Toast.makeText(activity, "解析安装包异常: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + companion object { + private const val TAG = "TVBrowser.Remote" + } +} diff --git a/app/src/main/java/com/tv/browser/VideoSnifferController.kt b/app/src/main/java/com/tv/browser/VideoSnifferController.kt new file mode 100644 index 0000000..29eee0c --- /dev/null +++ b/app/src/main/java/com/tv/browser/VideoSnifferController.kt @@ -0,0 +1,286 @@ +package com.tv.browser + +import android.content.Intent +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.View +import android.webkit.WebView +import android.widget.Button +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.Lifecycle + +/** + * 视频嗅探状态机与自动全屏倒计时控制器(从 BrowserActivity 拆分): + * 负责维护当前网页嗅探到的视频源列表、请求头回放缓存、站点选集信息、3 秒自动接管倒计时,以及启动原生/外部播放器。 + */ +class VideoSnifferController( + private val activity: AppCompatActivity, + private val webView: WebView, + private val btnNativePlay: Button, + private val btnFloatingPlay: Button, + private val isOnHomePageProvider: () -> Boolean, + private val currentPageTitleProvider: () -> String, + private val onSuspendWebMedia: () -> Unit +) { + val sniffedVideoList = mutableListOf() + var currentSniffedVideoUrl: String? = null + private set + var currentSniffedVideoTitle: String? = null + private set + var webOnlyNoticeShown: Boolean = false + private set + + val requestHeaderCache = VideoSnifferHelper.RequestHeaderCache() + var currentSiteInfo: SiteInfo? = null + var currentSiteInfoJson: String? = null + + private var autoPlayHandledPage: String? = null + private var autoPlayPage: String? = null + private var autoPlayRemaining = 0 + private val autoPlayHandler = Handler(Looper.getMainLooper()) + private val autoPlayTick = Runnable { onAutoPlayTick() } + + val hasAnyVideo: Boolean + get() = sniffedVideoList.isNotEmpty() || !currentSniffedVideoUrl.isNullOrBlank() + + /** 切换回首页时清空嗅探状态 */ + fun clearForHomePage() { + cancelAutoPlay(markHandled = false) + currentSniffedVideoUrl = null + currentSniffedVideoTitle = null + sniffedVideoList.clear() + btnNativePlay.visibility = View.GONE + btnFloatingPlay.visibility = View.GONE + } + + /** 新页面开始加载时重置嗅探状态 */ + fun resetForNewPage() { + cancelAutoPlay(markHandled = false) + currentSniffedVideoUrl = null + currentSniffedVideoTitle = null + sniffedVideoList.clear() + webOnlyNoticeShown = false + currentSiteInfo = null + currentSiteInfoJson = null + btnNativePlay.visibility = View.GONE + btnFloatingPlay.visibility = View.GONE + } + + /** 处理嗅探到的视频流(主线程调用) */ + fun handleVideoSniffed(url: String, title: String, kind: String, frameUrl: String?) { + if (isOnHomePageProvider()) return + val trimmed = url.trim() + if (trimmed.isEmpty()) return + + // 央视网等加密流:原生播放会花屏,留在网页里播放 + if (VideoSnifferHelper.isWebOnlyStream(trimmed)) { + Log.d(TAG, "Web-only stream, skip native: $trimmed") + if (!webOnlyNoticeShown) { + webOnlyNoticeShown = true + Toast.makeText(activity, VideoSnifferHelper.WEB_ONLY_MESSAGE, Toast.LENGTH_LONG).show() + } + return + } + val isHls = VideoSnifferHelper.isHls(trimmed, kind) + if (!isHls && !VideoSnifferHelper.isVideoStreamUrl(trimmed)) return + if (AdBlockHelper.isEnabled(activity) && AdBlockHelper.shouldBlockUrl(trimmed)) return + if (sniffedVideoList.any { it.url == trimmed || VideoSnifferHelper.isLikelyVariantOf(trimmed, it.url) }) return + + val effectiveTitle = title.ifBlank { currentPageTitleProvider().ifBlank { "在线影视" } } + val streamType = when { + isHls -> "HLS 极速流" + trimmed.contains(".mp4", ignoreCase = true) -> "MP4 标清/高清" + else -> "通用视频流" + } + sniffedVideoList.add(SniffedVideo(trimmed, effectiveTitle, streamType, kind, frameUrl)) + currentSniffedVideoUrl = trimmed + currentSniffedVideoTitle = effectiveTitle + Log.d(TAG, "Video sniffed[$kind]: $trimmed (total streams=${sniffedVideoList.size})") + + btnNativePlay.visibility = View.VISIBLE + btnFloatingPlay.visibility = View.VISIBLE + updatePlayButtonsText() + if (currentSiteInfo == null) { + webView.evaluateJavascript(VideoSnifferHelper.JS_SITE_ADAPTER_MACCMS, null) + } + if (isHls) maybeStartAutoPlay() + } + + fun updatePlayButtonsText() { + if (autoPlayRemaining > 0) { + btnFloatingPlay.text = "🎬 ${autoPlayRemaining} 秒后全屏播放 · 按返回取消" + } else if (sniffedVideoList.size > 1) { + btnFloatingPlay.text = "🎬 检测到 ${sniffedVideoList.size} 条视频线路 · 点此选线播放" + } else { + btnFloatingPlay.text = "🎬 检测到视频源 · 点此原生播放" + } + btnNativePlay.text = if (sniffedVideoList.size > 1) "🎬 线路(${sniffedVideoList.size})" else "🎬 原生播放" + } + + private fun currentPageKey(): String? = + webView.url?.takeIf { it.startsWith("http") }?.let { SiteInfo.normalizeUrl(it) } + + private fun maybeStartAutoPlay() { + if (!AppSettings.isAutoTakeover(activity) || isOnHomePageProvider() || autoPlayRemaining > 0) return + if (!activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return + val page = currentPageKey() ?: return + if (page == autoPlayHandledPage) return + autoPlayPage = page + autoPlayRemaining = AUTO_PLAY_SECONDS + Log.d(TAG, "auto play countdown start: $page") + updatePlayButtonsText() + autoPlayHandler.removeCallbacks(autoPlayTick) + autoPlayHandler.postDelayed(autoPlayTick, 1000) + } + + private fun onAutoPlayTick() { + if (autoPlayRemaining <= 0) return + val page = currentPageKey() + if (isOnHomePageProvider() || page == null || page != autoPlayPage) { + cancelAutoPlay(markHandled = false) + return + } + if (!activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + cancelAutoPlay(markHandled = true) + return + } + autoPlayRemaining-- + if (autoPlayRemaining > 0) { + updatePlayButtonsText() + autoPlayHandler.postDelayed(autoPlayTick, 1000) + return + } + autoPlayHandledPage = page + updatePlayButtonsText() + val video = sniffedVideoList.firstOrNull { VideoSnifferHelper.isHls(it.url, it.kind) } + ?: sniffedVideoList.firstOrNull() + if (video != null) { + Log.d(TAG, "auto play: ${video.url}") + startNativePlayer(video) + } + } + + /** 取消自动播放倒计时;[markHandled] 为 true 时本页不再自动播放 */ + fun cancelAutoPlay(markHandled: Boolean): Boolean { + if (autoPlayRemaining <= 0) return false + autoPlayHandler.removeCallbacks(autoPlayTick) + autoPlayRemaining = 0 + if (markHandled) autoPlayHandledPage = autoPlayPage + autoPlayPage = null + updatePlayButtonsText() + return true + } + + private fun buildVideoSource(video: SniffedVideo): VideoSource { + val pageUrl = webView.url?.takeIf { it.startsWith("http") } + val headers = VideoSnifferHelper.buildPlaybackHeaders( + url = video.url, + captured = requestHeaderCache.get(video.url), + userAgent = webView.settings.userAgentString ?: BrowserActivity.currentUserAgent(activity), + fallbackReferer = video.frameUrl ?: pageUrl + ) + val info = currentSiteInfo + val title = info?.displayTitle()?.takeIf { it.isNotBlank() } ?: video.title + return VideoSource( + url = video.url, + title = title, + pageUrl = pageUrl, + headers = headers, + isHls = VideoSnifferHelper.isHls(video.url, video.kind) + ) + } + + private fun siteInfoForCurrentPage(): SiteInfo? { + val info = currentSiteInfo ?: return null + val page = webView.url ?: return null + return if (SiteInfo.normalizeUrl(info.current) == SiteInfo.normalizeUrl(page)) info else null + } + + private fun startNativePlayer(video: SniffedVideo) { + cancelAutoPlay(markHandled = true) + currentPageKey()?.let { autoPlayHandledPage = it } + onSuspendWebMedia() + val source = buildVideoSource(video) + Log.d(TAG, "launch native: ${source.url} page=${source.pageUrl} headers=${source.headers.keys}") + PlayerActivity.start(activity, source, siteInfoForCurrentPage()) + } + + private fun noVideoMessage(): String = + if (webOnlyNoticeShown) VideoSnifferHelper.WEB_ONLY_MESSAGE else "暂未嗅探到可播放的视频源" + + /** 启动原生全屏播放器:多线路时弹出选择对话框 */ + fun launchNativePlayer() { + if (sniffedVideoList.isEmpty()) { + val url = currentSniffedVideoUrl + if (url.isNullOrBlank()) { + Toast.makeText(activity, noVideoMessage(), Toast.LENGTH_SHORT).show() + return + } + startNativePlayer(SniffedVideo(url, currentSniffedVideoTitle ?: currentPageTitleProvider(), "")) + return + } + val hlsList = sniffedVideoList.filter { VideoSnifferHelper.isHls(it.url, it.kind) } + if (sniffedVideoList.size == 1 || hlsList.size == 1) { + startNativePlayer(hlsList.firstOrNull() ?: sniffedVideoList[0]) + return + } + val items = sniffedVideoList.mapIndexed { idx, s -> "线路 ${idx + 1} [${s.type}]" }.toTypedArray() + AlertDialog.Builder(activity) + .setTitle("选择播放线路 (${sniffedVideoList.size} 条可用)") + .setItems(items) { _, which -> startNativePlayer(sniffedVideoList[which]) } + .setNegativeButton("取消", null) + .show() + } + + /** 调用系统外部已安装的专业播放器(Kodi / MX Player / VLC 等) */ + fun launchExternalPlayer() { + if (sniffedVideoList.isEmpty()) { + val url = currentSniffedVideoUrl + if (url.isNullOrBlank()) { + Toast.makeText(activity, noVideoMessage(), Toast.LENGTH_SHORT).show() + return + } + startExternalPlayerIntent(SniffedVideo(url, currentSniffedVideoTitle ?: currentPageTitleProvider(), "")) + return + } + if (sniffedVideoList.size == 1) { + startExternalPlayerIntent(sniffedVideoList[0]) + return + } + val items = sniffedVideoList.mapIndexed { idx, s -> "线路 ${idx + 1} [${s.type}]" }.toTypedArray() + AlertDialog.Builder(activity) + .setTitle("选择调用外部播放器的线路") + .setItems(items) { _, which -> startExternalPlayerIntent(sniffedVideoList[which]) } + .setNegativeButton("取消", null) + .show() + } + + private fun startExternalPlayerIntent(video: SniffedVideo) { + val source = buildVideoSource(video) + try { + onSuspendWebMedia() + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(Uri.parse(source.url), if (source.isHls) "application/x-mpegURL" else "video/*") + putExtra("title", source.title) + if (source.headers.isNotEmpty()) { + putExtra("headers", source.headers.flatMap { listOf(it.key, it.value) }.toTypedArray()) + } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + activity.startActivity(Intent.createChooser(intent, "选择外部播放器")) + } catch (e: Exception) { + Log.e(TAG, "External player launch failed", e) + Toast.makeText(activity, "未找到外部播放器,切换内置原生播放", Toast.LENGTH_SHORT).show() + PlayerActivity.start(activity, source, siteInfoForCurrentPage()) + } + } + + companion object { + private const val TAG = "TVBrowser.Video" + private const val AUTO_PLAY_SECONDS = 3 + } +} diff --git a/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt b/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt index 015ca43..afb6daa 100644 --- a/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt +++ b/app/src/main/java/com/tv/browser/VideoSnifferHelper.kt @@ -232,7 +232,7 @@ object VideoSnifferHelper { * 同时监听 {__tvcmd:'pause'} / {__tvcmd:'resume'} 消息,供原生层暂停网页内所有视频并停止 / 恢复 hls.js 缓冲(含 iframe)。 */ val JS_DOCUMENT_START_HOOK = """ - (function() { + (function __tvHook() { if (typeof window.artplayerPluginDanmaku !== 'function') { window.artplayerPluginDanmaku = function() { return function() { @@ -424,6 +424,33 @@ object VideoSnifferHelper { } catch (_) {} }); } catch (e) {} + // 旧版 WebView(不支持 DOCUMENT_START_SCRIPT)兜底:将 Hook 自动传递到可访问的子 iframe + try { + function injectChildFrames() { + var fs = document.getElementsByTagName('iframe'); + for (var i = 0; i < fs.length; i++) { + try { + var f = fs[i]; + var cw = f.contentWindow; + if (cw && !cw.__tvSniffInstalled) { + cw.eval('(' + __tvHook.toString() + ')()'); + } + if (!f.__tvHookBound) { + f.__tvHookBound = true; + f.addEventListener('load', function() { + try { + var w = this.contentWindow; + if (w && !w.__tvSniffInstalled) w.eval('(' + __tvHook.toString() + ')()'); + } catch (_) {} + }); + } + } catch (_) {} + } + } + injectChildFrames(); + document.addEventListener('DOMContentLoaded', injectChildFrames); + window.addEventListener('load', injectChildFrames); + } catch (e) {} })(); """.trimIndent() diff --git a/app/src/main/java/com/tv/browser/VodSearchDialogHelper.kt b/app/src/main/java/com/tv/browser/VodSearchDialogHelper.kt new file mode 100644 index 0000000..2c3ca8e --- /dev/null +++ b/app/src/main/java/com/tv/browser/VodSearchDialogHelper.kt @@ -0,0 +1,138 @@ +package com.tv.browser + +import android.content.Context +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.ArrayAdapter +import android.widget.EditText +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.ListView +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AlertDialog + +/** + * 跨站聚合搜片弹窗组件(VodSearchDialog): + * 独立承载「输入片名弹窗」与「多站点实时并发搜索结果列表弹窗」,为 BrowserActivity 减负。 + */ +typealias VodSearchDialog = VodSearchDialogHelper + +object VodSearchDialogHelper { + + fun showInputDialog( + context: Context, + prefill: String, + onSearch: (String) -> Unit + ) { + val input = EditText(context).apply { + setText(prefill) + setSelection(text.length) + hint = "输入片名,如:沧元图" + isSingleLine = true + imeOptions = EditorInfo.IME_ACTION_SEARCH + } + val pad = (20 * context.resources.displayMetrics.density).toInt() + val box = FrameLayout(context).apply { + setPadding(pad, pad / 2, pad, 0) + addView(input) + } + val dialog = AlertDialog.Builder(context) + .setTitle("全网搜片(在收藏的影视站里搜)") + .setView(box) + .setPositiveButton("搜索") { _, _ -> + input.text.toString().trim().takeIf { it.isNotEmpty() }?.let(onSearch) + } + .setNegativeButton(context.getString(R.string.close), null) + .create() + input.setOnEditorActionListener { _, _, _ -> + val kw = input.text.toString().trim() + if (kw.isNotEmpty()) { + dialog.dismiss() + onSearch(kw) + } + true + } + dialog.show() + input.requestFocus() + } + + fun showResultsDialog( + context: Context, + keyword: String, + helper: VodSearchHelper, + bookmarks: List, + userAgent: String, + onOpenUrl: (String) -> Unit + ) { + val sites = helper.collectSites(bookmarks) + val webEngine = SearchEngineHelper.getWebEngine(context) + if (sites.isEmpty()) { + Toast.makeText(context, "收藏里还没有影视站,先用${webEngine.name}搜索", Toast.LENGTH_SHORT).show() + onOpenUrl(SearchEngineHelper.buildSearchUrl(webEngine, keyword)) + return + } + + val allResults = mutableListOf() + val labels = mutableListOf() + val webLabel = "🌐 用${webEngine.name}搜索「$keyword」" + val pad = (20 * context.resources.displayMetrics.density).toInt() + val status = TextView(context).apply { + text = "正在 ${sites.size} 个收藏站点里搜索「$keyword」…" + textSize = 15f + setPadding(pad, pad / 2, pad, pad / 2) + } + val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, labels) + val list = ListView(context).apply { this.adapter = adapter } + val root = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(status) + addView( + list, + LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + (context.resources.displayMetrics.heightPixels * 0.55f).toInt() + ) + ) + } + fun refresh() { + labels.clear() + allResults.forEach { labels.add("${it.name}  · ${it.siteName}") } + labels.add(webLabel) + adapter.notifyDataSetChanged() + } + refresh() + + val dialog = AlertDialog.Builder(context) + .setTitle("搜片:$keyword") + .setView(root) + .setNegativeButton(context.getString(R.string.close), null) + .create() + list.setOnItemClickListener { _, _, position, _ -> + dialog.dismiss() + val r = allResults.getOrNull(position) + onOpenUrl(r?.detailUrl ?: SearchEngineHelper.buildSearchUrl(webEngine, keyword)) + } + dialog.setOnDismissListener { helper.cancel() } + dialog.show() + list.requestFocus() + + helper.search(keyword, sites, userAgent, object : VodSearchHelper.Listener { + override fun onSiteDone(site: String, results: List, done: Int, total: Int) { + if (!dialog.isShowing) return + if (results.isNotEmpty()) { + val merged = (allResults + results).sortedBy { VodSearchHelper.score(it.name, keyword) } + allResults.clear() + allResults.addAll(merged) + refresh() + } + val siteHits = allResults.map { it.host }.distinct().size + status.text = when { + done < total -> "正在搜索…($done/$total 个站点完成)已找到 ${allResults.size} 条" + allResults.isEmpty() -> "收藏的 $total 个站点里没找到「$keyword」,可用下方网页搜索" + else -> "搜索完成:$siteHits 个站点共 ${allResults.size} 条,选一个打开详情页" + } + } + }) + } +} diff --git a/app/src/main/java/com/tv/browser/VodSearchHelper.kt b/app/src/main/java/com/tv/browser/VodSearchHelper.kt index 2d90c51..33c2328 100644 --- a/app/src/main/java/com/tv/browser/VodSearchHelper.kt +++ b/app/src/main/java/com/tv/browser/VodSearchHelper.kt @@ -10,8 +10,15 @@ import org.json.JSONObject import java.net.HttpURLConnection import java.net.URL import java.net.URLEncoder +import java.security.SecureRandom +import java.security.cert.X509Certificate import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import javax.net.ssl.HostnameVerifier +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager /** 一条聚合搜索结果 */ data class VodResult( @@ -24,13 +31,9 @@ data class VodResult( /** * 跨站聚合搜片:在用户收藏的影视站里同时搜索同一个片名。 * - * 绝大多数盗版影视站都基于苹果 CMS(MacCMS),它的搜索页通常要验证码, - * 但联想接口 `/index.php/ajax/suggest?mid=1&wd=关键词` 不需要验证码,返回 JSON: - * `{code:1, list:[{id, name, en, pic}]}`。拿到 id 后再拼出详情页地址。 - * - * 详情页地址各站伪静态规则不同(/voddetail/123.html、/detail/123.html …), - * 首次搜索某站时抓一次首页,找出一个详情页链接推断模板并缓存;找不到时用 MacCMS 动态路由。 - * 非苹果 CMS 站点接口不存在 / 返回非 JSON,直接忽略。 + * 1. 苹果 CMS(MacCMS)联想接口 `/index.php/ajax/suggest?mid=1&wd=关键词`,无需验证码直接返回 JSON。 + * 2. WordPress / 现代影视站(如 4K影视 4kvm.tv、厂长资源 czzy77.com 等)HTML 搜索页兜底解析(`/?s=关键词`、`/xssearch?q=关键词`)。 + * 3. 对最近一次搜索词缓存 2 分钟结果,重复查看秒开。 */ class VodSearchHelper(context: Context) { @@ -42,7 +45,7 @@ class VodSearchHelper(context: Context) { private val appContext = context.applicationContext private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val mainHandler = Handler(Looper.getMainLooper()) - private var executor: ExecutorService? = null + private val runningFutures = ArrayList>() @Volatile private var generation = 0 /** 收藏里去重后的站点(按域名),返回 (origin, 站点名) */ @@ -62,65 +65,145 @@ class VodSearchHelper(context: Context) { fun search(keyword: String, sites: List>, userAgent: String, listener: Listener) { cancel() + val trimmedKw = keyword.trim() + val cached = getCachedSearch(trimmedKw, sites.size) + if (cached != null) { + mainHandler.post { + listener.onSiteDone("缓存", cached, sites.size, sites.size) + } + return + } + val gen = ++generation - val pool = Executors.newFixedThreadPool(THREADS).also { executor = it } val total = sites.size var done = 0 + val aggregated = ArrayList() val cookies = CookieManager.getInstance() - for ((origin, siteName) in sites) { - val cookie = runCatching { cookies.getCookie(origin) }.getOrNull() - pool.execute { - val results = runCatching { searchSite(origin, siteName, keyword, userAgent, cookie) } - .onFailure { Log.d(TAG, "$origin failed: ${it.message}") } - .getOrDefault(emptyList()) - mainHandler.post { - if (gen != generation) return@post - done++ - listener.onSiteDone(siteName, results, done, total) + synchronized(runningFutures) { + runningFutures.clear() + for ((origin, siteName) in sites) { + val cookie = runCatching { cookies.getCookie(origin) }.getOrNull() + val future = sharedExecutor.submit { + if (gen != generation) return@submit + val results = runCatching { searchSite(origin, siteName, trimmedKw, userAgent, cookie) } + .onFailure { Log.d(TAG, "$origin failed: ${it.message}") } + .getOrDefault(emptyList()) + mainHandler.post { + if (gen != generation) return@post + done++ + if (results.isNotEmpty()) aggregated.addAll(results) + if (done == total && aggregated.isNotEmpty()) { + putCachedSearch(trimmedKw, total, aggregated.toList()) + } + listener.onSiteDone(siteName, results, done, total) + } } + runningFutures.add(future) } } - pool.shutdown() } fun cancel() { generation++ - executor?.shutdownNow() - executor = null + synchronized(runningFutures) { + runningFutures.forEach { it.cancel(true) } + runningFutures.clear() + } } private fun searchSite( origin: String, siteName: String, keyword: String, ua: String, cookie: String? ): List { val kw = URLEncoder.encode(keyword, "UTF-8") - val body = httpGet("$origin/index.php/ajax/suggest?mid=1&wd=$kw&limit=30", ua, cookie, origin) - ?: return emptyList() - val json = runCatching { JSONObject(body.trim()) }.getOrNull() ?: return emptyList() - val list = json.optJSONArray("list") ?: return emptyList() - if (list.length() == 0) return emptyList() val host = Uri.parse(origin).host.orEmpty() - val sample = list.optJSONObject(0) - val template = detailTemplate( - origin, host, ua, cookie, - sample?.optString("id").orEmpty(), sample?.optString("name").orEmpty().trim() - ) - val out = ArrayList() - for (i in 0 until list.length()) { - val o = list.optJSONObject(i) ?: continue - val id = o.optString("id").ifBlank { o.optString("vod_id") } - val name = o.optString("name").ifBlank { o.optString("vod_name") }.trim() - if (id.isBlank() || name.isBlank()) continue - val path = template.replace("{id}", id) - out.add(VodResult(name, siteName, host, if (path.startsWith("http")) path else origin + path)) + + // 1. 尝试 MacCMS 标准 Ajax JSON 接口 + val body = httpGet("$origin/index.php/ajax/suggest?mid=1&wd=$kw&limit=30", ua, cookie, origin) + if (body != null) { + val json = runCatching { JSONObject(body.trim()) }.getOrNull() + val list = json?.optJSONArray("list") + if (list != null && list.length() > 0) { + val sample = list.optJSONObject(0) + val template = detailTemplate( + origin, host, ua, cookie, + sample?.optString("id").orEmpty(), sample?.optString("name").orEmpty().trim() + ) + val out = ArrayList() + for (i in 0 until list.length()) { + val o = list.optJSONObject(i) ?: continue + val id = o.optString("id").ifBlank { o.optString("vod_id") } + val name = o.optString("name").ifBlank { o.optString("vod_name") }.trim() + if (id.isBlank() || name.isBlank()) continue + val path = template.replace("{id}", id) + out.add(VodResult(name, siteName, host, if (path.startsWith("http")) path else origin + path)) + } + if (out.isNotEmpty()) { + Log.d(TAG, "$origin -> ${out.size} results (maccms tpl=$template)") + return out + } + } } - Log.d(TAG, "$origin -> ${out.size} results (tpl=$template)") - return out + + // 2. 兜底:WordPress / 4kvm / 厂长资源等站点的 HTML 搜索页解析 + return searchHtmlFallback(origin, host, siteName, keyword, kw, ua, cookie) + } + + /** + * 针对 4kvm.tv(/?s=关键词)、厂长资源(/xssearch?q=关键词 或 /?s=关键词)及通用影视站的 HTML 搜索结果提取 + */ + private fun searchHtmlFallback( + origin: String, + host: String, + siteName: String, + rawKeyword: String, + encodedKw: String, + ua: String, + cookie: String? + ): List { + val searchPaths = if (host.contains("czzy") || host.contains("4kcz")) { + listOf("/xssearch?q=$encodedKw", "/?s=$encodedKw") + } else { + listOf("/?s=$encodedKw", "/vodsearch/-------------.html?wd=$encodedKw") + } + val out = ArrayList() + val seenUrls = HashSet() + val kwLower = rawKeyword.lowercase() + + for (searchPath in searchPaths) { + val html = runCatching { httpGet(origin + searchPath, ua, cookie, origin, ajax = false) }.getOrNull() + ?: continue + for (m in HTML_RESULT_LINK.findAll(html)) { + var href = m.groupValues[1].trim() + val rawTitle = m.groupValues[2] + .replace(HTML_TAG_STRIP, "") + .replace(" ", " ") + .replace("&", "&") + .replace("–", "-") + .trim() + if (rawTitle.length < 2 || rawTitle.length > 60) continue + if (BAD_LINK_TEXT.containsMatchIn(rawTitle)) continue + if (!rawTitle.lowercase().contains(kwLower) && kwLower.length >= 2) continue + + if (href.startsWith("//")) href = Uri.parse(origin).scheme + ":" + href + else if (href.startsWith("/")) href = origin + href + if (!href.startsWith("http")) continue + val u = runCatching { Uri.parse(href) }.getOrNull() ?: continue + if (u.host != null && u.host != host) continue + if (!seenUrls.add(href)) continue + + out.add(VodResult(rawTitle, siteName, host, href)) + if (out.size >= 15) break + } + if (out.isNotEmpty()) { + Log.d(TAG, "$origin -> ${out.size} results (html $searchPath)") + return out + } + } + return emptyList() } /** * 推断详情页地址模板,如 `/voddetail/{id}.html`;结果按域名缓存。 - * 候选:首页里找到的本站详情链接 + 常见伪静态规则,逐个用 [sampleId] 实际请求验证 - * (返回 2xx 且页面里出现片名 [sampleName] 才算对)。 */ private fun detailTemplate( origin: String, host: String, ua: String, cookie: String?, sampleId: String, sampleName: String @@ -129,7 +212,6 @@ class VodSearchHelper(context: Context) { val candidates = LinkedHashSet() val html = runCatching { httpGet("$origin/", ua, cookie, origin, ajax = false) }.getOrNull() if (html != null) { - // 第一个匹配可能是广告外链,逐个找本站链接 for (m in DETAIL_HREF.findAll(html)) { var href = m.groupValues[1] if (href.startsWith("//")) href = Uri.parse(origin).scheme + ":" + href @@ -156,13 +238,16 @@ class VodSearchHelper(context: Context) { return tpl } } - // 都没验证通过(可能是网络问题),不缓存,下次再试 return candidates.first() } private fun httpGet(url: String, ua: String, cookie: String?, referer: String, ajax: Boolean = true): String? { val conn = URL(url).openConnection() as HttpURLConnection try { + if (conn is HttpsURLConnection) { + conn.sslSocketFactory = lenientSslSocketFactory + conn.hostnameVerifier = lenientHostnameVerifier + } conn.connectTimeout = TIMEOUT_MS conn.readTimeout = TIMEOUT_MS conn.instanceFollowRedirects = true @@ -173,7 +258,6 @@ class VodSearchHelper(context: Context) { if (!cookie.isNullOrBlank()) conn.setRequestProperty("Cookie", cookie) if (conn.responseCode !in 200..299) return null val bytes = conn.inputStream.use { input -> - // 首页可能很大,最多读 1MB val buf = java.io.ByteArrayOutputStream() val chunk = ByteArray(16 * 1024) while (buf.size() < MAX_BYTES) { @@ -195,6 +279,46 @@ class VodSearchHelper(context: Context) { private const val THREADS = 4 private const val TIMEOUT_MS = 6000 private const val MAX_BYTES = 1024 * 1024 + private const val CACHE_TTL_MS = 120_000L + + @Volatile private var lastSearchKey: String = "" + @Volatile private var lastSearchTime: Long = 0L + @Volatile private var lastSearchResults: List = emptyList() + + private fun getCachedSearch(keyword: String, siteCount: Int): List? { + val key = "${keyword.lowercase()}|$siteCount" + if (key == lastSearchKey && + lastSearchResults.isNotEmpty() && + System.currentTimeMillis() - lastSearchTime < CACHE_TTL_MS + ) { + return lastSearchResults + } + return null + } + + private fun putCachedSearch(keyword: String, siteCount: Int, results: List) { + lastSearchKey = "${keyword.lowercase()}|$siteCount" + lastSearchTime = System.currentTimeMillis() + lastSearchResults = results + } + + private val lenientHostnameVerifier = HostnameVerifier { _, _ -> true } + private val sharedExecutor: ExecutorService by lazy { + Executors.newFixedThreadPool(THREADS) { r -> + Thread(r, "VodSearchWorker").apply { isDaemon = true } + } + } + private val lenientSslSocketFactory by lazy { + val trustAll = arrayOf( + object : X509TrustManager { + override fun getAcceptedIssuers(): Array = emptyArray() + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + } + ) + SSLContext.getInstance("TLS").apply { init(null, trustAll, SecureRandom()) }.socketFactory + } + /** 常见的苹果 CMS 详情页伪静态规则(最后一个是动态路由,伪静态未开时也能用) */ private val COMMON_TEMPLATES = listOf( "/voddetail/{id}.html", @@ -204,7 +328,16 @@ class VodSearchHelper(context: Context) { ) private val DETAIL_HREF = Regex("href=[\"']([^\"'\\s]*?detail[^\"'\\s]*?\\d+[^\"'\\s]*?)[\"']", RegexOption.IGNORE_CASE) - /** 明显不是苹果 CMS 的大站,不浪费请求 */ + + /** 匹配 4kvm (/movies/..., /tvshows/...)、厂长 (/movie/...)、通用详情链接 */ + private val HTML_RESULT_LINK = Regex( + "]+href=[\"']([^\"'\\s]*(?:/movies/|/tvshows/|/movie/|/voddetail/|/detail/|/v_play/)[^\"'\\s]*)[\"'][^>]*>([\\s\\S]*?)", + RegexOption.IGNORE_CASE + ) + private val HTML_TAG_STRIP = Regex("<[^>]+>") + private val BAD_LINK_TEXT = Regex("^(首页|电影|电视剧|动漫|综艺|美剧|韩剧|分类|排行|全部|更多|播放|立即播放|查看详情)$") + + /** 明显不是影视资源站的通用大站,不浪费请求 */ private val SKIP_HOSTS = setOf( "baidu.com", "bing.com", "google.com", "sogou.com", "so.com", "bilibili.com", "iqiyi.com", "youku.com", "qq.com", "mgtv.com", "douban.com", "youtube.com",