commit a7224d19f0d9b1046b6c32e6d994a81cac893d02 Author: haomingming Date: Mon Nov 9 08:47:15 2026 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b12708f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/build/ +.idea/ +.gradle/* +*/release +*/debug +*/build/ +local.properties \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..598bec0 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# TV 浏览器 + +在 Android TV 上使用的简易网页浏览器应用,基于 WebView,支持遥控器 D-pad 导航。 + +## 功能 + +- **网页浏览**:使用系统 WebView 加载网页,支持 JavaScript。 +- **工具栏**:后退、前进、刷新、首页、地址栏、前往,均支持遥控器焦点。 +- **遥控器操作**: + - D-pad 在工具栏按钮和地址栏、WebView 之间切换焦点。 + - 中心键/确认键在地址栏按下可执行“前往”。 + - 返回键在可后退时先后退网页,否则退出应用。 +- **从外部打开链接**:支持作为浏览器处理 `http`/`https` 链接(如从其他应用“在浏览器中打开”)。 + +## 环境要求 + +- Android Studio Ladybug (2024.2.1) 或更高版本(推荐) +- JDK 17 +- 目标设备:Android TV(Android 5.0 / API 21+,且支持 Leanback) + +## 构建与运行 + +1. 用 **Android Studio** 打开本项目根目录(`tv` 文件夹)。 +2. 等待 Gradle 同步完成。 +3. 连接 Android TV 设备或启动 TV 模拟器: + - 设备:开启开发者选项与 USB 调试,用 USB 连接电脑。 + - 模拟器:**Device Manager** → **Create Device** → 选择 TV 设备(如 “TV (1080p)”)→ 选择系统镜像并启动。 +4. 点击 **Run**(绿色三角)或使用 `Shift+F10`,选择 TV 设备/模拟器,安装并运行应用。 + +在 TV 主屏的“应用”列表中可看到 **TV 浏览器**,进入后即可输入网址或使用默认首页进行浏览。 + +## 项目结构概览 + +``` +tv/ +├── app/ +│ ├── src/main/ +│ │ ├── AndroidManifest.xml # TV 与浏览器声明、权限 +│ │ ├── java/com/tv/browser/ +│ │ │ └── BrowserActivity.kt # 主界面与 WebView 逻辑 +│ │ └── res/ +│ │ ├── layout/activity_browser.xml +│ │ ├── values/strings.xml, colors.xml, themes.xml +│ │ └── drawable/ # 图标、焦点背景等 +│ ├── build.gradle.kts +│ └── proguard-rules.pro +├── gradle/ +│ ├── libs.versions.toml +│ └── wrapper/gradle-wrapper.properties +├── build.gradle.kts +├── settings.gradle.kts +└── README.md +``` + +## 自定义 + +- **默认首页**:在 `app/src/main/res/values/strings.xml` 中修改 `default_home_url`(当前为 `https://www.google.com`),可改为任意合法 URL。 +- **主题与颜色**:在 `res/values/colors.xml` 和 `themes.xml` 中调整。 +- **TV 主屏图标**:替换 `res/drawable/banner.xml` 或使用 320×180 的横条图(banner)资源。 + +## 许可 + +本项目仅供学习与参考使用。浏览器内核为系统 WebView,实际渲染与安全策略取决于设备系统版本。 + + + +启动模拟器 + +C:\Users\wucongxing\AppData\Local\Android\Sdk\emulator\emulator -avd Television_720p -dns-server 8.8.8.8,8.8.4.4 + +C:\Users\wucongxing\AppData\Local\Android\Sdk\platform-tools\adb forward tcp:8767 tcp:8766 +--- + +## 手机遥控 · 模拟器调试(电脑访问 127.0.0.1:8767 打不开时排查) + +电脑浏览器访问 `http://127.0.0.1:8767` 需要先做 **adb 端口转发**(`adb forward`;Windows 上 `reverse` 可能不监听,用 `forward`),并按下面顺序排查。 + +### 1. 确认设备已连接 + +```bash +adb devices +``` + +应看到至少一行设备(如 `emulator-5554 device`)。若为 `offline` 或 `unauthorized`,先处理连接/授权。 + +### 2. 多设备时指定当前 TV 设备 + +若列表里有多台设备,必须指定要调试的那台: + +```bash +adb -s emulator-5554 forward tcp:8767 tcp:8766 +``` + +把 `emulator-5554` 换成你 `adb devices` 里那台 TV 模拟器的序列号。 + +### 3. 使用 adb forward(Windows 上 reverse 可能不监听,用 forward) + +```bash +adb forward --remove tcp:8767 +adb forward tcp:8767 tcp:8766 +``` + +多设备时加 `-s <设备号>`,例如:`adb -s emulator-5554 forward tcp:8767 tcp:8766`。 + +### 4. 确认转发已生效 + +```bash +adb forward --list +``` + +应出现一行:`emulator-5554 tcp:8767 tcp:8766`(或你的设备号)。 + +### 5. 先在模拟器里启动遥控服务(必须) + +- 在 **TV 模拟器**里打开 **TV 浏览器**,点击工具栏 **「手机遥控」**(弹窗会打开,服务启动;关掉弹窗后服务也会常驻)。 +- 在模拟器里点弹窗的 **「在电视上打开」**,应能打开遥控页;若 TV 里能打开,说明本机 8766 服务正常。 +- **未在模拟器里点过「手机遥控」时,电脑访问 8767 会连不上。** + +### 6. 在电脑上再试 8767 + +确认上一步 TV 里能打开 127.0.0.1:8766 后,在电脑执行:`Invoke-WebRequest -Uri http://127.0.0.1:8767/ -UseBasicParsing` 或浏览器访问 `http://127.0.0.1:8767`。 + +### 7. 电脑仍连不上 8767 时 + +按顺序做: + +1. **确认本机 8767 有进程在听**(PowerShell 或 CMD): + ```powershell + netstat -ano | findstr :8767 + ``` + 应有至少一行 `LISTENING`(多为 adb)。若**没有任何输出**,说明 adb 未在本机监听 8767,可尝试: + - 用设备号再绑一次:`adb -s emulator-5554 forward --remove tcp:8767`,再 `adb -s emulator-5554 forward tcp:8767 tcp:8766` + - 重启 adb:`adb kill-server`,再 `adb start-server`,再执行上述 forward + - 用**管理员身份**打开 PowerShell/CMD 再执行 `adb forward tcp:8767 tcp:8766` + +2. **确认模拟器里服务已启动**:在 TV 模拟器里打开 TV 浏览器 → 点一次 **「手机遥控」**(可再关弹窗)→ 在 TV 里点「在电视上打开」能打开 127.0.0.1:8766 的页面。若 TV 里都打不开,先解决模拟器内 8766 再试电脑 8767。 + +3. **本机防火墙**:临时关 Windows 防火墙或为“专用/本地”网络放行,再试 `Invoke-WebRequest -Uri http://127.0.0.1:8767/ -UseBasicParsing`。 + +4. **换用 curl 试**(若已安装):`curl -v http://127.0.0.1:8767/` + +### 8. 服务常驻说明 + +设备上的 8766 服务在**点过一次「手机遥控」后常驻**,关弹窗不关服务。请务必先完成「5. 先在模拟器里启动遥控服务」再在电脑访问 8767。 + +### 9. 在电脑上确认 8767 是否通 + +在 **PowerShell** 或 **CMD** 里执行: + +```powershell +curl -v http://127.0.0.1:8767/ +``` + +或在 PowerShell: + +```powershell +(Invoke-WebRequest -Uri http://127.0.0.1:8767/ -UseBasicParsing -TimeoutSec 5).StatusCode +``` + +- 若返回 **200** 或能看到 HTML:说明端口通,再试浏览器(可换无痕/别的浏览器)。 +- 若 **连接被拒绝 / Connection refused**:多半是 TV 里弹窗没开或服务未启动,请先在 TV 里打开「手机遥控」弹窗再执行上述命令。 +- 若 **超时**:检查本机防火墙或杀毒是否拦截本地 8767。 + +### 10. 一键执行(单设备时) + +```bash +adb forward --remove tcp:8767 +adb forward tcp:8767 tcp:8766 +adb forward --list +``` + +看到 `tcp:8767 tcp:8766` 后,**在 TV 应用里点一次「手机遥控」启动服务**,再在电脑访问 `http://127.0.0.1:8767`。 \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..388107c --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.tv.browser" + compileSdk = 34 + + defaultConfig { + applicationId = "com.tv.browser" + minSdk = 21 + targetSdk = 34 + versionCode = 11 + versionName = "4.5.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + viewBinding = true + // 生成 BuildConfig.VERSION_NAME,版本号只需在上面 defaultConfig 改一处 + buildConfig = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.constraintlayout) + implementation(libs.leanback) + implementation(libs.fragment.ktx) + implementation(libs.recyclerview) + implementation(libs.nanohttpd) + implementation(libs.nanohttpd.websocket) + implementation(libs.zxing.core) + // 原生播放器:ExoPlayer(支持 HLS、自定义请求头、倍速) + implementation(libs.media3.exoplayer) + implementation(libs.media3.exoplayer.hls) + implementation(libs.media3.ui) + // 页面执行前注入脚本(可进入所有 iframe) + implementation(libs.androidx.webkit) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..07abe4d --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,11 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# Keep WebView related classes +-keepclassmembers class * extends android.webkit.WebViewClient { + public void *(android.webkit.WebView, java.lang.String); + public boolean *(android.webkit.WebView, java.lang.String); +} +-keepclassmembers class * extends android.webkit.WebChromeClient { + public void *(android.webkit.WebView, java.lang.String); +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f9198d5 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/remote.html b/app/src/main/assets/remote.html new file mode 100644 index 0000000..9f133c9 --- /dev/null +++ b/app/src/main/assets/remote.html @@ -0,0 +1,605 @@ + + + + + + 小北浏览器 · 触屏遥控 + + + +
+ 小北浏览器 · 遥控器 +
+ +
+ + 正在连接电视... +
+ + +
+
+ + 一键推送链接到电视 +
+
+ + +
+
+ + +
+
+ + 无线快传 (APK / 视频 / 文件到电视) +
+
+ + +
+ +
+ + +
+
+ +
+

单指滑动移动光标 · 轻击确认

+

双指上下滑动滚动网页

+
+ + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/app/src/main/java/com/tv/browser/AdBlockHelper.kt b/app/src/main/java/com/tv/browser/AdBlockHelper.kt new file mode 100644 index 0000000..0e3140a --- /dev/null +++ b/app/src/main/java/com/tv/browser/AdBlockHelper.kt @@ -0,0 +1,159 @@ +package com.tv.browser + +import android.content.Context +import android.net.Uri +import android.util.Log +import android.webkit.WebResourceResponse +import java.io.ByteArrayInputStream + +/** + * 影视站轻量级去广告与防劫持工具类: + * 1. 域名与资源拦截(百度网盟、Tanx、Google Ads、统计代码等) + * 2. 恶意跳转协议拦截(阻止 tbopen://, alipays:// 等导致白屏或弹窗错误) + * 3. 动态 CSS 元素隐藏注入(清理悬浮横幅、弹窗遮罩、伪造全屏按钮) + * 4. 禁用恶意 window.open 弹窗劫持 + */ +object AdBlockHelper { + + private const val TAG = "TVBrowser.AdBlock" + private const val PREFS_NAME = "tv_browser_adblock" + private const val KEY_ADBLOCK_ENABLED = "adblock_enabled" + + /** 常见广告与跟踪域名黑名单关键词 */ + private val AD_HOST_PATTERNS = arrayOf( + "pos.baidu.com", + "cpro.baidustatic.com", + "mobads.baidu.com", + "tanx.com", + "alimama.com", + "strip.alicdn.com", + "mi.gdt.qq.com", + "pgdt.gtimg.cn", + "hm.baidu.com", + "cnzz.com", + "umeng.com", + "51.la", + "googlesyndication.com", + "googleadservices.com", + "pagead2.googlesyndication.com", + "doubleclick.net", + "adservice", + "adsystem", + "adx.", + "adnxs", + "union.baidu.com", + "union.sogou.com" + ) + + /** 常见恶意拦截跳转 App 协议头(电视端通常无对应 App,会导致 ERR_UNKNOWN_URL_SCHEME 报错白屏) */ + private val BLOCKED_SCHEMES = arrayOf( + "tbopen", + "alipays", + "alipay", + "weixin", + "pddopen", + "snssdk", + "kwai", + "openapp.jdmobile", + "meituanwaimai", + "dianping", + "vipshop", + "market", + "intent" + ) + + /** 是否开启去广告(默认开启) */ + fun isEnabled(context: Context): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.getBoolean(KEY_ADBLOCK_ENABLED, true) + } + + /** 设置去广告开关 */ + fun setEnabled(context: Context, enabled: Boolean) { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit().putBoolean(KEY_ADBLOCK_ENABLED, enabled).apply() + } + + /** 检查 URL 是否为广告网络请求 */ + fun shouldBlockUrl(url: String): Boolean { + val lower = url.lowercase() + return AD_HOST_PATTERNS.any { pattern -> lower.contains(pattern) } + } + + /** 生成空响应拦截广告请求 */ + fun createEmptyResponse(): WebResourceResponse { + return WebResourceResponse( + "text/plain", + "UTF-8", + ByteArrayInputStream(ByteArray(0)) + ) + } + + /** 检查 URL 是否属于恶意广告跳转协议 */ + fun isHijackScheme(url: String): Boolean { + val lower = url.lowercase().trim() + if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("about:") || lower.startsWith("javascript:")) { + return false + } + val scheme = try { + Uri.parse(url).scheme?.lowercase() ?: "" + } catch (_: Exception) { + "" + } + return BLOCKED_SCHEMES.any { s -> s.equals(scheme, ignoreCase = true) } + } + + /** + * 强力元素隐藏与弹窗防劫持注入脚本: + * 1. 隐藏高层级遮罩、悬浮关闭广告、通用横幅容器 + * 2. 覆盖 window.open,防止影视网站静默弹窗跳出 + * 3. MutationObserver 监听动态插入的广告节点 + */ + val JS_INJECT_ADBLOCK = """ + (function() { + try { + if (window.__tvAdBlockInjected) return; + window.__tvAdBlockInjected = true; + + // 1. 阻止未经用户主动操作的 window.open 弹窗劫持 + var origOpen = window.open; + window.open = function(url, target, features) { + console.log('TVBrowser AdBlock: blocked popup window.open ->', url); + return null; + }; + + // 2. 注入全局去广告 CSS 规则 + var css = [ + '[id*="google_ads"]', '[class*="google_ads"]', + '[id*="pos_"]', '[class*="ad-banner"]', '[id*="pop-ad"]', '[class*="pop-ad"]', + '[id*="adv-"]', '[class*="adv-"]', '[id*="_ad_"]', '[class*="_ad_"]', + 'div[style*="z-index: 999999"]', 'div[style*="z-index: 2147483647"]', + '.popup-box', '.app-download-bar', '.bottom-fixed-ad', '.float-ad', + 'iframe[src*="union"]', 'iframe[src*="ad."]', 'iframe[src*="/ad/"]' + ].join(', ') + ' { display: none !important; width: 0 !important; height: 0 !important; visibility: hidden !important; pointer-events: none !important; }'; + + var style = document.createElement('style'); + style.type = 'text/css'; + style.id = '__tv_adblock_css'; + style.appendChild(document.createTextNode(css)); + (document.head || document.documentElement).appendChild(style); + + // 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]; + var z = parseInt(window.getComputedStyle(el).zIndex) || 0; + if (z >= 99999 && !el.querySelector('video')) { + el.style.setProperty('display', 'none', 'important'); + } + } + } + cleanPopups(); + try { + new MutationObserver(cleanPopups).observe(document.documentElement || document.body, { childList: true, subtree: true }); + } catch(e) {} + } catch(e) {} + })(); + """.trimIndent() +} diff --git a/app/src/main/java/com/tv/browser/AppSettings.kt b/app/src/main/java/com/tv/browser/AppSettings.kt new file mode 100644 index 0000000..51ac9ef --- /dev/null +++ b/app/src/main/java/com/tv/browser/AppSettings.kt @@ -0,0 +1,95 @@ +package com.tv.browser + +import android.content.Context + +/** + * 应用级小设置: + * - 自动接管播放(嗅探到视频后倒计时自动进入原生播放器) + * - 按剧记住的片头 / 片尾时长(跳过片头片尾) + */ +object AppSettings { + private const val PREFS = "tv_browser_settings" + private const val KEY_AUTO_TAKEOVER = "auto_takeover" + 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 SKIP_PREFIX = "skip|" + + /** 解码兼容模式 */ + const val DECODER_AUTO = 0 // 自动(默认推荐 · 硬件优先,失败回退) + const val DECODER_HARDWARE = 1 // 强制硬解(GPU加速 · 省电高效) + const val DECODER_SOFTWARE = 2 // 强制软解(CPU解码 · 解决花屏/黑屏/绿屏) + + private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + fun isAutoTakeover(context: Context): Boolean = prefs(context).getBoolean(KEY_AUTO_TAKEOVER, true) + + fun setAutoTakeover(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean(KEY_AUTO_TAKEOVER, enabled).apply() + } + + fun getDecoderMode(context: Context): Int = prefs(context).getInt(KEY_DECODER_MODE, DECODER_AUTO) + + fun setDecoderMode(context: Context, mode: Int) { + prefs(context).edit().putInt(KEY_DECODER_MODE, mode).apply() + } + + fun decoderModeName(mode: Int): String = when (mode) { + DECODER_HARDWARE -> "强制硬解 (GPU)" + DECODER_SOFTWARE -> "强制软解 (CPU)" + else -> "自动" + } + + fun isPreloadNext(context: Context): Boolean = prefs(context).getBoolean(KEY_PRELOAD_NEXT, true) + + fun setPreloadNext(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean(KEY_PRELOAD_NEXT, enabled).apply() + } + + /** 净化播放页(默认关闭):识别到影视播放页时只保留播放器、线路与选集 */ + fun isPurifyPage(context: Context): Boolean = prefs(context).getBoolean(KEY_PURIFY_PAGE, false) + + fun setPurifyPage(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean(KEY_PURIFY_PAGE, enabled).apply() + } + + /** 片头片尾设置:introMs = 片头结束位置;outroMs = 片尾长度(距结尾多少毫秒开始算片尾) */ + data class SkipConfig(val introMs: Long, val outroMs: Long) { + val isEmpty: Boolean get() = introMs <= 0 && outroMs <= 0 + } + + fun getSkip(context: Context, seriesKey: String): SkipConfig { + if (seriesKey.isBlank()) return SkipConfig(0, 0) + val raw = prefs(context).getString(SKIP_PREFIX + seriesKey, null) ?: return SkipConfig(0, 0) + val parts = raw.split(',') + return SkipConfig(parts.getOrNull(0)?.toLongOrNull() ?: 0, parts.getOrNull(1)?.toLongOrNull() ?: 0) + } + + fun setSkip(context: Context, seriesKey: String, config: SkipConfig) { + if (seriesKey.isBlank()) return + val editor = prefs(context).edit() + if (config.isEmpty) editor.remove(SKIP_PREFIX + seriesKey) + else editor.putString(SKIP_PREFIX + seriesKey, "${config.introMs.coerceAtLeast(0)},${config.outroMs.coerceAtLeast(0)}") + editor.apply() + } + + /** 清空所有剧集的片头片尾跳过记忆 */ + fun clearAllSkips(context: Context) { + val p = prefs(context) + val editor = p.edit() + for (key in p.all.keys) { + if (key.startsWith(SKIP_PREFIX)) { + editor.remove(key) + } + } + editor.apply() + } + + /** 剧集分组 key:站点主域 + 剧名(同一站点同一部剧共用片头片尾设置与追剧记录) */ + fun seriesKey(pageUrl: String?, seriesTitle: String): String { + if (seriesTitle.isBlank()) return "" + val host = pageUrl?.let { runCatching { android.net.Uri.parse(it).host }.getOrNull() } + val domain = ContentFilterHelper.registrableDomain(host) ?: return "" + return "$domain|${seriesTitle.trim()}" + } +} diff --git a/app/src/main/java/com/tv/browser/Bookmark.kt b/app/src/main/java/com/tv/browser/Bookmark.kt new file mode 100644 index 0000000..eaae255 --- /dev/null +++ b/app/src/main/java/com/tv/browser/Bookmark.kt @@ -0,0 +1,22 @@ +package com.tv.browser + +import android.net.Uri + +/** + * 收藏项:URL 与标题。 + */ +data class Bookmark( + val url: String, + val title: String +) { + fun displayTitle(): String = if (title.isNotBlank()) title else url + + fun displayHost(): String { + return try { + val uri = Uri.parse(url) + uri.host?.removePrefix("www.") ?: url + } catch (_: Exception) { + url + } + } +} diff --git a/app/src/main/java/com/tv/browser/BookmarkAdapter.kt b/app/src/main/java/com/tv/browser/BookmarkAdapter.kt new file mode 100644 index 0000000..88cb6e6 --- /dev/null +++ b/app/src/main/java/com/tv/browser/BookmarkAdapter.kt @@ -0,0 +1,42 @@ +package com.tv.browser + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import com.tv.browser.databinding.ItemBookmarkBinding + +/** + * 收藏列表适配器,支持点击打开、移除。 + */ +class BookmarkAdapter( + private val context: Context, + private var list: List, + private val onOpen: (Bookmark) -> Unit, + private val onRemove: (Bookmark) -> Unit +) : BaseAdapter() { + + fun updateList(newList: List) { + list = newList + notifyDataSetChanged() + } + + override fun getCount(): Int = list.size + override fun getItem(position: Int): Bookmark = list[position] + override fun getItemId(position: Int): Long = position.toLong() + + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + val binding = if (convertView == null) { + ItemBookmarkBinding.inflate(LayoutInflater.from(context), parent, false) + } else { + ItemBookmarkBinding.bind(convertView) + } + val item = getItem(position) + binding.bookmarkTitle.text = item.displayTitle() + binding.bookmarkUrl.text = item.url + binding.root.setOnClickListener { onOpen(item) } + binding.btnRemove.setOnClickListener { onRemove(item) } + return binding.root + } +} diff --git a/app/src/main/java/com/tv/browser/BookmarkRepository.kt b/app/src/main/java/com/tv/browser/BookmarkRepository.kt new file mode 100644 index 0000000..3bcac82 --- /dev/null +++ b/app/src/main/java/com/tv/browser/BookmarkRepository.kt @@ -0,0 +1,130 @@ +package com.tv.browser + +import android.content.Context +import android.content.SharedPreferences +import org.json.JSONArray +import org.json.JSONObject + +/** + * 使用 SharedPreferences 持久化收藏列表。 + */ +class BookmarkRepository(context: Context) { + + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + private val keyBookmarks = "bookmarks" + 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 + } + val list = parse(json) + return migrateDefaults(list) + } + + private fun parse(json: String?): List { + if (json.isNullOrBlank()) { + return emptyList() + } + return try { + val arr = JSONArray(json) + List(arr.length()) { i -> + val obj = arr.getJSONObject(i) + Bookmark( + url = obj.getString(KEY_URL), + title = obj.optString(KEY_TITLE, "") + ) + } + } catch (_: Exception) { + emptyList() + } + } + + /** 老用户升级:把之后版本新增的默认站点补到最前面(只补一次) */ + private fun migrateDefaults(list: List): List { + val version = prefs.getInt(KEY_DEFAULTS_VERSION, 1) + if (version >= DEFAULTS_VERSION) return list + val added = (version + 1..DEFAULTS_VERSION) + .flatMap { DEFAULTS_ADDED_BY_VERSION[it].orEmpty() } + .filter { b -> list.none { it.url == b.url } } + val result = if (added.isEmpty()) list else added + list + if (added.isNotEmpty()) save(result) + prefs.edit().putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION).apply() + return result + } + + fun add(bookmark: Bookmark) { + val list = getAll().toMutableList() + list.removeAll { it.url == bookmark.url } + list.add(0, bookmark) + save(list) + } + + fun remove(url: String) { + val list = getAll().filter { it.url != url } + save(list) + } + + 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) + }) + } + prefs.edit().putString(keyBookmarks, arr.toString()).apply() + } + + companion object { + private const val PREFS_NAME = "tv_browser_bookmarks" + private const val KEY_URL = "url" + private const val KEY_TITLE = "title" + private const val KEY_DEFAULTS_VERSION = "bookmarks_defaults_version" + + /** 默认收藏版本:新增默认站点时 +1,并在 [DEFAULTS_ADDED_BY_VERSION] 登记 */ + private const val DEFAULTS_VERSION = 4 + + /** 各版本新增的默认站点:老用户升级时一次性补入(已存在则跳过,之后被删也不再补) */ + private val DEFAULTS_ADDED_BY_VERSION = mapOf( + 2 to listOf(Bookmark(url = "https://www.mtyy3.com/", title = "麦田影院")), + 3 to listOf( + Bookmark(url = "https://www.1905.com/", title = "1950电影网"), + Bookmark(url = "http://www.docuchina.cn/", title = "中国纪录片网"), + Bookmark(url = "https://tv.cctv.com/", title = "CCTV节目官网_央视网"), + Bookmark(url = "https://yangshipin.cn/", title = "央视频 - 有品质的视频社交媒体") + ), + 4 to listOf( + Bookmark(url = "https://www.czzy77.com/", title = "厂长资源"), + Bookmark(url = "https://www.4kvm.tv/", title = "4K影视"), + ) + ) + + /** 收藏夹为空时默认加入的收藏 */ + private val DEFAULT_BOOKMARKS = listOf( + Bookmark(url = "https://www.4kvm.tv/", title = "4K影视"), + Bookmark(url = "https://www.czzy77.com/", title = "厂长资源"), + Bookmark(url = "https://www.1905.com/", title = "1950电影网"), + Bookmark(url = "http://www.docuchina.cn/", title = "中国纪录片网"), + Bookmark(url = "https://tv.cctv.com/", title = "CCTV节目官网_央视网"), + Bookmark(url = "https://yangshipin.cn/", title = "央视频 - 有品质的视频社交媒体"), + Bookmark(url = "https://www.mtyy3.com/", title = "麦田影院"), + Bookmark(url = "https://www.ppnix.com/cn/", title = "ppnix"), + Bookmark(url = "https://www.ymck.pro/", title = "ymck"), + Bookmark(url = "https://www.ncat1.app/", title = "网肥猫"), + Bookmark(url = "https://www.keke1.app/", title = "可可"), + Bookmark(url = "https://www.dmxq19.com/index/home.html", title = "大米"), + Bookmark(url = "https://www.xuelewang.com/", title = "蜂鸟影院"), + Bookmark(url = "https://www.bjjq168.com/", title = "星辰影院"), + ) + } +} diff --git a/app/src/main/java/com/tv/browser/BookmarksDialog.kt b/app/src/main/java/com/tv/browser/BookmarksDialog.kt new file mode 100644 index 0000000..9d3f2fc --- /dev/null +++ b/app/src/main/java/com/tv/browser/BookmarksDialog.kt @@ -0,0 +1,87 @@ +package com.tv.browser + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Window +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import com.tv.browser.databinding.DialogBookmarksBinding + +/** + * 收藏夹对话框:列表展示、打开、移除,适配 TV 焦点。 + */ +class BookmarksDialog : DialogFragment() { + + var onOpenUrl: ((String) -> Unit)? = null + /** 弹窗关闭时回调(用于恢复光标等) */ + var onDismissCallback: (() -> Unit)? = null + + private val repo: BookmarkRepository by lazy { + BookmarkRepository(requireContext()) + } + + private var binding: DialogBookmarksBinding? = null + private var adapter: BookmarkAdapter? = null + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogBookmarksBinding.inflate(LayoutInflater.from(requireContext())) + val view = binding!!.root + + adapter = BookmarkAdapter( + context = requireContext(), + list = repo.getAll(), + onOpen = { b -> + onOpenUrl?.invoke(b.url) + dismiss() + }, + onRemove = { b -> + repo.remove(b.url) + refreshList() + Toast.makeText(requireContext(), getString(R.string.bookmark_removed), Toast.LENGTH_SHORT).show() + } + ) + + binding!!.bookmarkList.adapter = adapter + binding!!.bookmarkList.setOnItemClickListener { _, _, position, _ -> + adapter?.getItem(position)?.let { onOpenUrl?.invoke(it.url); dismiss() } + } + binding!!.btnClose.setOnClickListener { dismiss() } + + refreshList() + + val dialog = AlertDialog.Builder(requireContext()) + .setView(view) + .create() + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) + dialog.setOnDismissListener { onDismissCallback?.invoke() } + dialog.setOnShowListener { + binding?.bookmarkList?.let { list -> + list.requestFocus() + if (list.count > 0) list.setSelection(0) + } + } + return dialog + } + + override fun onStart() { + super.onStart() + (activity as? BrowserActivity)?.setCursorOverlayVisible(false, this) + } + + override fun onDestroyView() { + (activity as? BrowserActivity)?.setCursorOverlayVisible(true, this) + super.onDestroyView() + binding = null + } + + private fun refreshList() { + val list = repo.getAll() + adapter?.updateList(list) + binding?.let { b -> + b.bookmarkList.visibility = if (list.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE + b.emptyHint.visibility = if (list.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE + } + } +} diff --git a/app/src/main/java/com/tv/browser/BrowserActivity.kt b/app/src/main/java/com/tv/browser/BrowserActivity.kt new file mode 100644 index 0000000..1bfd61a --- /dev/null +++ b/app/src/main/java/com/tv/browser/BrowserActivity.kt @@ -0,0 +1,2585 @@ +package com.tv.browser + +import android.annotation.SuppressLint +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.media.AudioManager +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.speech.RecognizerIntent +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.webkit.ConsoleMessage +import android.webkit.SslErrorHandler +import android.webkit.WebChromeClient +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.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.fragment.app.DialogFragment +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 + +/** + * 视频嗅探条目(支持多线路检测与切换) + */ +data class SniffedVideo( + val url: String, + val title: String, + val type: String, + /** 发现方式:net / xhr / fetch / hls / video / dom */ + val kind: String = "", + /** 发现该视频的 frame 地址(iframe 播放器页),作为 Referer 兜底 */ + val frameUrl: String? = null +) + +/** + * Android TV 浏览器主界面:WebView + 工具栏,支持遥控器 D-pad 导航、手机网页遥控。 + */ +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" + internal const val KEY_ZOOM_LEVEL = "key_zoom_level" + 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 绑定) */ + fun currentUserAgent(context: Context): String { + val desktop = context.getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE) + .getBoolean(KEY_IS_DESKTOP_UA, false) + return if (desktop) DESKTOP_UA else MOBILE_UA + } + } + + internal var currentZoomLevel: Int = 100 + internal var isDesktopUa: Boolean = false + private var isToolbarVisible: Boolean = true + + internal lateinit var binding: ActivityBrowserBinding + private lateinit var webView: WebView + private lateinit var urlBar: EditText + private lateinit var progressBar: ProgressBar + private lateinit var bookmarkRepository: BookmarkRepository + private lateinit var historyRepository: HistoryRepository + 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 + + /** 当前主页面的主机名与是否执行强力过滤(shouldInterceptRequest 在后台线程读取) */ + @Volatile private var currentPageHost: String? = null + @Volatile private var contentFilterActive: Boolean = false + + /** + * 已切到原生 / 外部播放器、网页视频被暂停:期间拒绝网页的子资源请求(shouldInterceptRequest 在后台线程读取)。 + * 网页播放器暂停后仍会继续缓冲分片(部分站点还带 P2P 加速),会和原生播放器抢网速;回到本页或打开新页面时解除。 + */ + @Volatile private var webMediaSuspended: 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() + 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) + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityBrowserBinding.inflate(layoutInflater) + setContentView(binding.root) + + contentLayout = binding.root.findViewById(R.id.contentLayout) + cursorOverlay = binding.root.findViewById(R.id.cursorOverlay) + webView = binding.webView + urlBar = binding.urlBar + progressBar = binding.progressBar + bookmarkRepository = BookmarkRepository(this) + historyRepository = HistoryRepository(this) + userScriptRepository = UserScriptRepository(this) + playbackHistoryRepository = PlaybackHistoryRepository(this) + + // 开启硬件加速(WebView 全屏视频必须) + window.setFlags( + android.view.WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED, + android.view.WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED + ) + + // 地址栏:允许调起电视系统输入法(遥控器确认键 / 光标点击时弹出,见 setupUrlBarKeys) + // 注意:不要设置 showSoftInputOnFocus = false,否则电视键盘永远无法弹出 + + // 预建 WebView 缓存目录,避免部分 TV 上 opendir/写索引失败导致崩溃或打不开页 + ensureWebViewCacheDirs() + + // 触屏遥控:每次打开应用就启动服务,关闭应用时在 onDestroy 停止 + ensureRemoteControlServerStarted() + // 初始化遥控状态指示器并启动定时刷新 + initRemoteStatusDot() + 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") + + val prefs = getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE) + currentZoomLevel = prefs.getInt(KEY_ZOOM_LEVEL, 100) + isDesktopUa = prefs.getBoolean(KEY_IS_DESKTOP_UA, false) + + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + setSupportZoom(false) + 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) + } + } + + webView.webViewClient = object : WebViewClient() { + 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 + } + loadUrl(targetUrl) + return true + } + + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest? + ): WebResourceResponse? { + val reqUrl = request?.url?.toString() ?: return null + // 原生 / 外部播放器播放中:网页不再加载任何子资源(分片、清单刷新、P2P 回源、广告轮询),把网速留给播放器 + if (request.isForMainFrame) { + webMediaSuspended = false + } else if (webMediaSuspended) { + return VideoSnifferHelper.createRejectedResponse() + } + // 0. 记录请求头,原生播放器回放时使用(Referer / UA 等) + 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() + } + // 2. 视频流嗅探拦截(.m3u8, .mp4 等) + if (VideoSnifferHelper.isVideoStreamUrl(reqUrl)) { + onVideoSniffed(reqUrl, currentPageTitle, "net") + } + return super.shouldInterceptRequest(view, request) + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + progressBar.visibility = View.VISIBLE + if (!isOnHomePage && url != "about:blank") urlBar.setText(url ?: "") + 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 + 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) + } + // 强力过滤:隐藏可疑动图 / 色情博彩广告 + updateContentFilterState(url) + if (contentFilterActive) view?.evaluateJavascript(ContentFilterHelper.JS_CONTENT_FILTER, null) + } + + override fun onPageCommitVisible(view: WebView?, url: String?) { + super.onPageCommitVisible(view, url) + injectDesktopModeFallback(view) + // 新页面首帧可见时再注入一次,尽量在广告图显示前隐藏 + if (contentFilterActive) view?.evaluateJavascript(ContentFilterHelper.JS_CONTENT_FILTER, null) + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + progressBar.visibility = View.GONE + if (!isOnHomePage && url != "about:blank") urlBar.setText(url ?: "") + 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 (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 }) } + } + + @Deprecated("Deprecated in Java") + 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? + ) { + super.onReceivedError(view, request, error) + if (request?.isForMainFrame == true) { + progressBar.visibility = View.GONE + Toast.makeText(this@BrowserActivity, getString(R.string.error_page), Toast.LENGTH_SHORT).show() + } + } + + 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() + } + } + + webView.webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + progressBar.progress = newProgress + if (newProgress >= 100) progressBar.visibility = View.GONE + } + + override fun onReceivedTitle(view: WebView?, title: String?) { + currentPageTitle = title?.trim().orEmpty() + val currentUrl = view?.url + 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()}") + } + return false + } + + /** 实现此回调后,WebView 才会向页面报告支持全屏,视频/页面的 requestFullscreen 才会生效 */ + override fun onShowCustomView(view: View?, callback: WebChromeClient.CustomViewCallback?) { + handleShowCustomView(view, callback) + } + + @Deprecated("Deprecated in Java") + override fun onShowCustomView( + view: View?, + requestedOrientation: Int, + callback: WebChromeClient.CustomViewCallback? + ) { + handleShowCustomView(view, callback) + } + + override fun onHideCustomView() { + exitCustomViewFullscreen() + } + } + + 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.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( + bookmarkRepository.getAll(), + onItemClick = { bookmark -> loadUrl(bookmark.url) }, + onDelete = { bookmark -> + val oldIndex = bookmarkRepository.getAll().indexOfFirst { it.url == bookmark.url } + 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 { + binding.homeGrid.findViewHolderForAdapterPosition(target)?.itemView?.requestFocus() + ?: binding.btnMouseMode.requestFocus() + } + } else { + binding.btnMouseMode.requestFocus() + } + } + ) + 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 + + webView.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) 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 onFragmentViewDestroyed(fm: FragmentManager, f: androidx.fragment.app.Fragment) { + if (f is androidx.fragment.app.DialogFragment) setCursorOverlayVisible(true, f) + } + override fun onFragmentDestroyed(fm: FragmentManager, f: androidx.fragment.app.Fragment) { + if (f is androidx.fragment.app.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() + } + } + } + + /** 是否为 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) { + super.onConfigurationChanged(newConfig) + if (::binding.isInitialized) { + applyResponsiveLayout() + cursorOverlay?.post { clampCursorToBounds() } + } + } + + /** 显示首页:收藏夹卡片网格,不加载网址;并关闭所有 WebView */ + private fun showHomePage() { + cancelAutoPlay(markHandled = false) + closeAllWebViews() + isOnHomePage = true + currentSniffedVideoUrl = null + currentSniffedVideoTitle = null + sniffedVideoList.clear() + binding.btnNativePlay.visibility = View.GONE + binding.btnFloatingPlay.visibility = View.GONE + setToolbarVisible(true) + updateHomeStatusBar() + exitCardEditing() + updateToolbarButtons() + updateCursorOverlayVisibility() + binding.homeGrid.visibility = View.VISIBLE + binding.webContainer.visibility = View.GONE + urlBar.setText("") + urlBar.hint = getString(R.string.url_hint) + urlBar.setNextFocusDownId(View.NO_ID) + (binding.homeGrid.adapter as? HomeCardAdapter)?.updateList(bookmarkRepository.getAll()) + 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 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 + } + + /** 关闭所有 WebView:停止加载、加载空白页、清空历史,释放当前页面 */ + private fun closeAllWebViews() { + try { + webView.stopLoading() + webView.loadUrl("about:blank") + webView.clearHistory() + } catch (_: Exception) { } + } + + /** 显示网页:隐藏首页,显示 WebView */ + private fun showWebPage() { + isOnHomePage = false + binding.homeStatusBar.visibility = View.GONE + binding.layoutContinueWatching.visibility = View.GONE + setToolbarVisible(true) + exitCardEditing() + updateToolbarButtons() + updateCursorOverlayVisibility() + binding.homeGrid.visibility = View.GONE + binding.webContainer.visibility = View.VISIBLE + 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() + } + } + + 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) { } + } + } + + private fun goFromUrlBar() { + hideKeyboard() + val input = urlBar.text.toString().trim() + if (input.isNotEmpty() && !SearchEngineHelper.isLikelyUrl(input) && SearchEngineHelper.isVodDefault(this)) { + showVodSearch(input) + return + } + val url = when { + input.isEmpty() -> homeUrl + else -> SearchEngineHelper.resolveUrl(this, input) + } + loadUrl(url) + } + + /** 电视遥控器语音输入搜索 (P4) */ + private fun startVoiceSearch() { + try { + val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { + putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + putExtra(RecognizerIntent.EXTRA_PROMPT, "请对准遥控器说出搜索关键词或网址") + } + @Suppress("DEPRECATION") + startActivityForResult(intent, REQUEST_CODE_VOICE_SEARCH) + } catch (e: ActivityNotFoundException) { + Toast.makeText(this, "当前电视设备未安装语音识别服务,可使用手机遥控打字", Toast.LENGTH_LONG).show() + } + } + + @Deprecated("Deprecated in Java") + 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() + if (voiceText.isNotBlank()) { + urlBar.setText(voiceText) + if (!SearchEngineHelper.isLikelyUrl(voiceText) && SearchEngineHelper.isVodDefault(this)) { + showVodSearch(voiceText) + return + } + val targetUrl = SearchEngineHelper.resolveUrl(this, voiceText) + loadUrl(targetUrl) + } + } + } + + /** 收起软键盘 */ + 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 loadUrl(url: String) { + showWebPage() + webView.loadUrl(url) + } + + /** 供遥控弹窗等调用:在浏览器主 WebView 中打开指定 URL(如 127.0.0.1:8766 自测) */ + fun loadUrlInBrowser(url: String) { + loadUrl(url) + } + + private fun addCurrentToBookmarks() { + val url = webView.url?.trim().orEmpty().ifEmpty { urlBar.text.toString().trim() } + if (url.isEmpty()) { + 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)) + 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") + } + + internal fun showUserScriptsDialog() { + UserScriptsDialog().show(supportFragmentManager, "user_scripts") + } + + private fun runVideoJs(js: String, onResult: (String) -> Unit = {}) { + webView.evaluateJavascript(js) { result -> + runOnUiThread { onResult(unwrapJsString(result)) } + } + } + + /** 触发页面内视频全屏:先 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 + } + + /** 按来源隐藏 / 恢复光标;[token] 一般传弹窗自身 */ + fun setCursorOverlayVisible(visible: Boolean, token: Any) { + if (visible) cursorHideTokens.remove(token) else cursorHideTokens.add(token) + updateCursorOverlayVisibility() + } + + /** 根据当前页面(首页/网页)切换工具栏按钮的显示 */ + 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() + 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 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 + + 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() + webMediaSuspended = true + } + + /** 回到本页:解除请求拦截,恢复网页 hls.js 缓冲(不自动播放) */ + private fun resumeWebMediaIfSuspended() { + if (!webMediaSuspended) return + webMediaSuspended = false + try { + webView.evaluateJavascript(VideoSnifferHelper.JS_RESUME_MEDIA_LOADING, null) + } catch (_: Exception) { } + } + + /** + * 防跳转劫持:跨站的主页面跳转,只有和用户 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 + } + + /** 根据页面地址更新"强力过滤"状态:全局开关 + 站点白名单(可在 WebView 后台线程调用) */ + private fun updateContentFilterState(pageUrl: String?) { + 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) { + 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) }) + + 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() + } + + /** 广告拦截开关 */ + 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() + } + + /** 不良动图过滤设置:强力过滤 / 本站不过滤(白名单)/ 无图模式 */ + 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() + } + } + 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() + } + + /** 搜索并展示结果:各站点结果陆续加入,点击打开详情页 */ + 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%(特大视界)" + ) + 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 applyZoom(zoomPercent: Int) { + currentZoomLevel = zoomPercent + webView.settings.minimumFontSize = (12 * zoomPercent / 100).coerceIn(1, 12) + 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() + } + } + + /** 系统 WebView 不支持页面执行前注入时,电脑版视口脚本在页面回调里尽早注入(脚本内部防重) */ + private fun injectDesktopModeFallback(view: WebView?) { + if (isDesktopUa && desktopViewportScript == null) { + view?.evaluateJavascript(UserAgentHelper.JS_DESKTOP_MODE, null) + } + } + + /** 工具栏平滑显隐:全屏或首页时始终显示,网页模式向下滚动时隐藏以获得 100% 满屏视野 */ + private fun setToolbarVisible(visible: Boolean) { + if (isOnHomePage) { + binding.toolbarScroll.visibility = View.VISIBLE + binding.toolbarScroll.translationY = 0f + isToolbarVisible = true + return + } + if (isAppFullscreen || fullscreenContainer != null) return + if (isToolbarVisible == visible) return + isToolbarVisible = visible + if (visible) { + binding.toolbarScroll.visibility = View.VISIBLE + 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() + } + } + + /** 更新首页顶部状态条:时钟、网络与手机遥控入口(整行已完全隐藏) */ + private fun updateHomeStatusBar() { + binding.homeStatusBar.visibility = View.GONE + } + + /** 检查网络诊断与互联网连通性 */ + 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.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 + } + } + + /** 在光标位置向整页(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) + uri.host == "127.0.0.1" && uri.port == getRemoteControlPort() + } catch (_: Exception) { 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.action == KeyEvent.ACTION_DOWN) { + val jsKey = when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> "ArrowUp" + KeyEvent.KEYCODE_DPAD_DOWN -> "ArrowDown" + KeyEvent.KEYCODE_DPAD_LEFT -> "ArrowLeft" + KeyEvent.KEYCODE_DPAD_RIGHT -> "ArrowRight" + 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 + } + } + } + return super.dispatchKeyEvent(event) + } + + /** 进入全屏 CustomView 处理:支持机型差异、沉浸模式并隐藏工具栏与光标 */ + private fun handleShowCustomView(view: View?, callback: WebChromeClient.CustomViewCallback?) { + if (view == null || callback == null) return + exitCustomViewFullscreen() + + // 隐藏工具栏,启用沉浸式全屏 + 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() + } + } + } + + @android.webkit.JavascriptInterface + fun onExitFullscreen() { + runOnUiThread { + Log.d(TAG_VIDEO, "FullscreenBridge: onExitFullscreen") + if (isAppFullscreen) { + exitAppFullscreen() + } + } + } + + @android.webkit.JavascriptInterface + fun onFullscreenAttempted() { + mainHandler.postDelayed({ + if (fullscreenContainer == null && !isAppFullscreen && !isOnHomePage) { + Log.d(TAG_VIDEO, "Fullscreen timeout: fallback to app fullscreen") + enterAppFullscreen() + } + }, 800) + } + } + + /** 防跳转劫持桥接:页面在捕获阶段上报用户真实点击的链接(未点在链接上时为空串) */ + inner class NavGuardBridge { + @android.webkit.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") + } + + /** document-start Hook 上报:XHR/fetch/hls.js/video.src 发出的媒体请求 */ + @android.webkit.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 + 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 + fun onSiteInfo(json: String) { + val info = SiteInfo.fromJson(json) ?: return + runOnUiThread { + currentSiteInfo = info + 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) + } + } + } + + @android.webkit.JavascriptInterface + fun onBookmark() { + runOnUiThread { addCurrentToBookmarks() } + } + } + + /** 用当前页的站点适配结果执行净化(脚本内部判断是否为播放页、是否找得到播放器) */ + private fun applyPurify(resetDisabled: Boolean) { + if (isOnHomePage) return + val json = 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) + } + Toast.makeText( + this, + if (enable) "已开启净化播放页:识别到播放页时只保留播放器与选集" else "已关闭净化播放页", + Toast.LENGTH_SHORT + ).show() + } + + override fun onRemoteMove(dx: Int, dy: Int) { + runOnUiThread { moveCursor(dx, dy) } + } + + 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 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 onRemoteOpenUrl(url: String) { + val trimmed = url.trim() + if (trimmed.isEmpty()) return + runOnUiThread { + val targetUrl = when { + !trimmed.startsWith("http://", ignoreCase = true) && !trimmed.startsWith("https://", ignoreCase = 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 + } + + // 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 + } + + // 3)回退:写入地址栏 + val target = urlBar + val editable = target.text + editable.replace(0, editable.length, text) + target.requestFocus() + target.setSelection(text.length) + } + } + + /** 局域网快传接收:处理 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() + } + + override fun onDestroy() { + vodSearchHelper?.cancel() + mainHandler.removeCallbacks(remoteStatusChecker) + try { + remoteControlServer?.stop() + } catch (_: Exception) { } + remoteControlServer = null + dialogCursorLifecycleCallbacks?.let { supportFragmentManager.unregisterFragmentLifecycleCallbacks(it) } + dialogCursorLifecycleCallbacks = null + try { + webView.stopLoading() + (webView.parent as? ViewGroup)?.removeView(webView) + webView.destroy() + } catch (_: Exception) { } + super.onDestroy() + } + + override fun onRestart() { + super.onRestart() + // 从桌面重新打开 App 时:若播放器正以画中画小窗播放,恢复为全屏播放器 + PlayerActivity.restoreFromPipIfNeeded(this) + } + + override fun onResume() { + super.onResume() + // 从原生 / 外部播放器回来:恢复网页加载 + if (::webView.isInitialized) resumeWebMediaIfSuspended() + // 从播放器返回首页时刷新追剧列表(进度 / 下一集) + if (isOnHomePage && ::binding.isInitialized) updateContinueWatchingBanner() + // 自愈:回到前台时重新计算光标显示状态 + if (::binding.isInitialized) updateCursorOverlayVisibility() + } + + override fun onPause() { + cancelAutoPlay(markHandled = true) + super.onPause() + } + + @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() + return + } + } + when { + fullscreenContainer != null && fullscreenContainer?.parent != null -> { + exitCustomViewFullscreen() + } + isAppFullscreen -> { + 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/ContentFilterHelper.kt b/app/src/main/java/com/tv/browser/ContentFilterHelper.kt new file mode 100644 index 0000000..77bffa8 --- /dev/null +++ b/app/src/main/java/com/tv/browser/ContentFilterHelper.kt @@ -0,0 +1,398 @@ +package com.tv.browser + +import android.content.Context +import android.net.Uri +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import java.io.ByteArrayInputStream +import java.util.Locale + +/** + * 不良动图 / 色情广告过滤("强力过滤"): + * 1. 网络层:拦截第三方域名的 GIF 动图、路径带广告特征的图片(不下载,最省流量) + * 2. 页面结构层:注入脚本,按"外链 + 动图 + 横幅比例 + 悬浮 + 敏感词"打分,命中即隐藏 + * 3. 站点规则:针对具体站点的屏蔽选择器 + * 4. 站点白名单:误伤时可对单个站点关闭过滤 + * 5. 无图模式:彻底不加载网络图片(兜底) + */ +object ContentFilterHelper { + + private const val PREFS_NAME = "tv_browser_content_filter" + private const val KEY_STRONG_FILTER = "strong_filter_enabled" + private const val KEY_NO_IMAGE = "no_image_mode" + private const val KEY_WHITELIST = "filter_whitelist_hosts" + + /** 广告图片常见路径特征 */ + private val AD_PATH_PATTERNS = arrayOf( + "/ad/", "/ads/", "/adv/", "/advert", "/guanggao", "/gg/", "/banner", + "/union/", "/popup", "/tuiguang", "_ad.", "-ad.", "/ad_", "/ads_" + ) + + /** 视频相关路径关键词:命中时不按"可疑脚本"拦截,避免误伤解析接口 / 清单 / 分片 */ + private val VIDEO_PATH_HINTS = arrayOf( + "m3u8", ".ts", ".mp4", ".key", ".json", "/api", "video", "play", "vod", "/jx", "parse", "/hls", "/dash" + ) + + /** 双段公共后缀(如 com.cn),用于粗略计算主域 */ + private val SECOND_LEVEL_SUFFIX = setOf("com", "net", "org", "gov", "edu", "co", "ac") + + // ---------------- 开关 ---------------- + + /** 强力过滤可疑动图(默认开启) */ + fun isStrongFilterEnabled(context: Context): Boolean = + prefs(context).getBoolean(KEY_STRONG_FILTER, true) + + fun setStrongFilterEnabled(context: Context, enabled: Boolean) { + prefs(context).edit().putBoolean(KEY_STRONG_FILTER, enabled).apply() + } + + /** 无图模式(默认关闭) */ + fun isNoImageMode(context: Context): Boolean = + prefs(context).getBoolean(KEY_NO_IMAGE, false) + + fun setNoImageMode(context: Context, enabled: Boolean) { + 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 + } + + fun setWhitelisted(context: Context, host: String?, whitelisted: Boolean) { + val domain = registrableDomain(host) ?: return + val set = prefs(context).getStringSet(KEY_WHITELIST, emptySet()).orEmpty().toMutableSet() + if (whitelisted) set.add(domain) else set.remove(domain) + prefs(context).edit().putStringSet(KEY_WHITELIST, set).apply() + } + + /** 当前站点是否需要执行强力过滤 */ + fun shouldFilter(context: Context, pageHost: String?): Boolean = + isStrongFilterEnabled(context) && !isWhitelisted(context, pageHost) + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // ---------------- 网络层 ---------------- + + /** + * 判断子资源请求是否为可疑广告图片: + * - 第三方域名(与页面、Referer 主域都不同)的 GIF + * - 任意域名下路径带广告特征的图片 + * 调用方需先确认 [shouldFilter]。可在 WebView 后台线程调用。 + */ + fun shouldBlockImage(request: WebResourceRequest, pageHost: String?): Boolean { + if (request.isForMainFrame) return false + val uri = request.url ?: return false + val path = (uri.path ?: "").lowercase(Locale.ROOT) + val accept = request.requestHeaders?.entries + ?.firstOrNull { it.key.equals("Accept", ignoreCase = true) }?.value.orEmpty() + val isGif = path.endsWith(".gif") + val isImage = isGif || accept.startsWith("image/") || + path.endsWith(".webp") || path.endsWith(".png") || path.endsWith(".jpg") || path.endsWith(".jpeg") + if (!isImage) return false + + if (AD_PATH_PATTERNS.any { path.contains(it) }) return true + if (!isGif) return false + + val reqDomain = registrableDomain(uri.host) ?: return false + val pageDomain = registrableDomain(pageHost) + val refererHost = request.requestHeaders?.entries + ?.firstOrNull { it.key.equals("Referer", ignoreCase = true) }?.value + ?.let { runCatching { Uri.parse(it).host }.getOrNull() } + val refererDomain = registrableDomain(refererHost) + return reqDomain != pageDomain && reqDomain != refererDomain + } + + /** 被拦截图片返回空 GIF 响应 */ + fun createEmptyImageResponse(): WebResourceResponse = + WebResourceResponse("image/gif", null, ByteArrayInputStream(ByteArray(0))) + + /** + * 可疑第三方脚本:与页面主域不同、且使用非标准端口(如 :9003、:21233)的 JS。 + * 这类脚本几乎都是广告联盟 / 点击劫持 / 统计,且常以同步方式放在 head 顶部, + * 服务器慢时会阻塞整页解析(表现为只显示顶栏、内容空白、点击无反应)。 + * 调用方需先确认 [shouldFilter]。可在 WebView 后台线程调用。 + */ + fun shouldBlockScript(request: WebResourceRequest, pageHost: String?): Boolean { + if (request.isForMainFrame) return false + val uri = request.url ?: return false + val port = uri.port + if (port == -1 || port == 80 || port == 443) return false + val path = (uri.path ?: "").lowercase(Locale.ROOT) + val headers = request.requestHeaders.orEmpty() + fun header(name: String) = headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value.orEmpty() + val accept = header("Accept") + // iframe 页面(如解析播放器)、图片、视频、清单等都不拦(部分站点的视频 CDN / 解析接口就走非标准端口) + if (accept.contains("text/html") || accept.startsWith("image/") || + accept.contains("video") || accept.contains("mpegurl")) return false + if (VIDEO_PATH_HINTS.any { path.contains(it) }) return false + // 只处理无扩展名或 .js 的地址(广告联盟脚本通常形如 /d/1198?c=1、/tjjs/?k=xxx) + val lastSeg = path.substringAfterLast('/') + if (lastSeg.contains('.') && !lastSeg.endsWith(".js")) return false + val pageDomain = registrableDomain(pageHost) + val reqDomain = registrableDomain(uri.host) ?: return false + if (reqDomain == pageDomain) return false + // 只拦主页面直接发起的请求;播放器 iframe 内部的请求 Referer 是播放器自己的域名 + val refererDomain = registrableDomain(runCatching { Uri.parse(header("Referer")).host }.getOrNull()) + if (refererDomain != null && refererDomain != pageDomain) return false + return true + } + + /** 被拦截脚本返回空 JS */ + fun createEmptyScriptResponse(): WebResourceResponse = + WebResourceResponse("application/javascript", "utf-8", ByteArrayInputStream(ByteArray(0))) + + /** 粗略计算主域:a.b.example.com -> example.com,x.example.com.cn -> example.com.cn */ + fun registrableDomain(host: String?): String? { + val h = host?.trim()?.lowercase(Locale.ROOT)?.trimEnd('.') ?: return null + if (h.isEmpty()) return null + if (h.all { it.isDigit() || it == '.' } || h.contains(':')) return h // IP + val parts = h.split('.') + if (parts.size <= 2) return h + val tld = parts[parts.size - 1] + val sld = parts[parts.size - 2] + val take = if (tld.length == 2 && sld in SECOND_LEVEL_SUFFIX) 3 else 2 + return parts.takeLast(take.coerceAtMost(parts.size)).joinToString(".") + } + + // ---------------- 页面结构层 ---------------- + + /** + * 页面过滤脚本:可重复注入(内部防重),持续监听动态插入的节点。 + * 打分规则(≥3 分隐藏): + * GIF 图 +2;链接指向外站 +2;无真实链接(javascript:/无 href 但可点击) +1; + * 横幅比例(宽≥3×高) +1;位于 fixed/sticky 悬浮层 +1;第三方 iframe +1; + * 文字/链接含博彩色情等敏感词 +3;链接指向本站 -2;含 video 的区域直接跳过。 + */ + val JS_CONTENT_FILTER = """ + (function() { + try { + if (window.__tvContentFilter) { window.__tvContentFilter(); return; } + var SITE_RULES = { + 'mtyy3.com': ['.ads_w', '#popup-container'], + 'mtyy1.cc': ['.ads_w', '#popup-container'] + }; + var GENERIC_SELECTORS = [ + '.ads_w', '.ads-box', '.ad-box', '.adsbox', '.ad_box', '.gg-box', + '[class*="guanggao"]', '[id*="guanggao"]', '[class*="ads_img"]', + '[class^="ads_"]', '[id^="ads_"]', '[id^="ad_"]', '[class*=" ads_"]', + 'a[href*="/ad/"] > img', 'a[href*="/ads/"] > img' + ]; + var BAD_WORDS = /(色情|成人|约炮|裸聊|裸体|激情|少妇|人妻|萝莉|美女直播|一夜情|同城|上门|丝袜|福利姬|黄色|av女优|博彩|赌场|棋牌|彩票|百家乐|澳门|葡京|威尼斯|金沙|太阳城|开元|体育投注|真人娱乐|老虎机|下注|充值送|首充|捕鱼|电子游艺|xvideos|pornhub|onlyfans)/i; + + function domainOf(host) { + host = (host || '').toLowerCase().replace(/\.$/, ''); + if (!host || /^[\d.]+$/.test(host)) return host; + var p = host.split('.'); + if (p.length <= 2) return host; + var tld = p[p.length - 1], sld = p[p.length - 2]; + var take = (tld.length === 2 && /^(com|net|org|gov|edu|co|ac)$/.test(sld)) ? 3 : 2; + return p.slice(-take).join('.'); + } + var pageDomain = domainOf(location.hostname); + + function hostOf(url) { + try { return new URL(url, location.href).hostname; } catch (e) { return ''; } + } + function isExternal(url) { + if (!url || !/^(https?:)?\/\//i.test(url)) return false; + var d = domainOf(hostOf(url)); + return !!d && d !== pageDomain; + } + + function hide(el, reason) { + if (!el || el.__tvFiltered) return; + el.__tvFiltered = true; + el.setAttribute('data-tv-filtered', reason || '1'); + el.style.setProperty('display', 'none', 'important'); + el.style.setProperty('visibility', 'hidden', 'important'); + window.__tvFilteredCount = (window.__tvFilteredCount || 0) + 1; + } + + // 1. 站点规则 + 通用广告容器:CSS 立即生效 + var selectors = GENERIC_SELECTORS.slice(); + Object.keys(SITE_RULES).forEach(function(d) { + if (pageDomain === d) selectors = selectors.concat(SITE_RULES[d]); + }); + if (!document.getElementById('__tv_content_filter_css')) { + var style = document.createElement('style'); + style.id = '__tv_content_filter_css'; + style.appendChild(document.createTextNode(selectors.join(',') + + '{display:none !important;visibility:hidden !important;}' + + // WOW.js 滚动动画:内容先隐藏、滚动到位才显示,脚本慢/报错时会一直看不见也点不到 + '.wow{visibility:visible !important;animation-name:none !important;}')); + (document.head || document.documentElement).appendChild(style); + } + + function inFixedLayer(el) { + var n = el, depth = 0; + while (n && n !== document.body && depth < 8) { + var pos = ''; + try { pos = window.getComputedStyle(n).position; } catch (e) {} + if (pos === 'fixed' || pos === 'sticky') return true; + n = n.parentElement; depth++; + } + return false; + } + function nearVideo(el) { + var n = el, depth = 0; + while (n && depth < 4) { + if (n.tagName === 'VIDEO' || (n.querySelector && n.querySelector('video'))) return true; + n = n.parentElement; depth++; + } + return false; + } + function bgImage(el) { + try { + var bg = window.getComputedStyle(el).backgroundImage || ''; + var m = bg.match(/url\(["']?([^"')]+)["']?\)/); + return m ? m[1] : ''; + } catch (e) { return ''; } + } + + // 2. 启发式打分 + function check(el) { + if (!el || el.__tvFiltered || el.__tvChecked) return; + var rect = el.getBoundingClientRect(); + var w = rect.width, h = rect.height; + if (el.tagName === 'IMG' && (!el.complete || (w === 0 && h === 0))) { + if (!el.__tvWait) { + el.__tvWait = true; + el.addEventListener('load', function() { check(el); }, { once: true }); + } + if (w === 0 && h === 0) return; + } + if (w > 0 && h > 0 && w < 40 && h < 40) { el.__tvChecked = true; return; } // 小图标 + if (nearVideo(el)) { el.__tvChecked = true; return; } + + var src = el.tagName === 'IMG' ? (el.currentSrc || el.src || el.getAttribute('data-src') || '') + : el.tagName === 'IFRAME' ? (el.src || '') : bgImage(el); + var link = el.tagName === 'A' ? el : (el.closest ? el.closest('a') : null); + var href = link ? (link.getAttribute('href') || '') : ''; + var text = ((el.getAttribute('alt') || '') + ' ' + (el.getAttribute('title') || '') + ' ' + + (link ? (link.getAttribute('title') || '') + ' ' + href : '')).slice(0, 500); + + var score = 0; + if (/\.gif(\?|#|$)/i.test(src)) score += 2; + if (link) { + if (isExternal(href)) score += 2; + else if (!href || /^javascript:/i.test(href) || href === '#') score += 1; + else score -= 2; + } + if (el.tagName === 'IFRAME' && isExternal(src)) score += 1; + if (w >= 200 && h > 0 && w >= h * 3) score += 1; + if (inFixedLayer(el)) score += 1; + var decodedSrc = src; + try { decodedSrc = decodeURIComponent(src); } catch (e) {} + if (BAD_WORDS.test(text) || BAD_WORDS.test(decodedSrc.slice(0, 300))) score += 3; + + el.__tvChecked = true; + if (score >= 3) hide(link && link !== document.body ? link : el, 'score' + score); + } + + // 3. 悬浮层检测:反拦截广告常把动图切成多个小块,用 base64 背景图拼接, + // 挂在 body 下、position:fixed、z-index 极高、类名随机(如 tVBxXii),网络层和 img 扫描都抓不到 + var GIF_BG = /(\.gif(\?|#|["')]|$))|data:image\/gif|base64,R0lGOD/i; + var blockedClasses = {}; + function rawBg(el) { + try { return window.getComputedStyle(el).backgroundImage || ''; } catch (e) { return ''; } + } + function hasGifInside(el) { + if (GIF_BG.test(rawBg(el))) return true; + var nodes = el.querySelectorAll ? el.querySelectorAll('*') : []; + for (var i = 0; i < nodes.length && i < 40; i++) { + var n = nodes[i]; + if (n.tagName === 'IMG' && /(\.gif(\?|#|$))|data:image\/gif|base64,R0lGOD/i.test(n.currentSrc || n.src || '')) return true; + if (GIF_BG.test(rawBg(n))) return true; + } + return false; + } + function hasAnyImage(el) { + if (rawBg(el).indexOf('url(') >= 0) return true; + return !!(el.querySelector && el.querySelector('img, [style*="background"]')); + } + function blockClass(el) { + var cls = String(el.className || '').trim(); + // 仅对单一、非通用的类名加 CSS 规则,防止广告脚本重新显示 / 重建同类节点 + if (!cls || /\s/.test(cls) || blockedClasses[cls] || cls.length < 4) return; + if (!/^[A-Za-z][\w-]*$/.test(cls)) return; + blockedClasses[cls] = true; + var s = document.getElementById('__tv_content_filter_css'); + if (s) s.appendChild(document.createTextNode('.' + cls + '{display:none !important;visibility:hidden !important;}')); + } + function checkFloating(el) { + if (!el || el.__tvFiltered || el.nodeType !== 1) return; + var tag = el.tagName; + if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'LINK' || tag === 'VIDEO' || tag === 'HEAD') return; + var cs; + try { cs = window.getComputedStyle(el); } catch (e) { return; } + if (cs.display === 'none') return; + var pos = cs.position; + var z = parseInt(cs.zIndex, 10) || 0; + var floating = pos === 'fixed' || pos === 'sticky' || (pos === 'absolute' && z >= 9999); + if (!floating) return; + if (el.querySelector && el.querySelector('video')) return; + // 几乎全透明的悬浮层:看不见却会抢走点击(点击劫持),直接移出点击范围 + var op = parseFloat(cs.opacity); + if (pos === 'fixed' && !isNaN(op) && op > 0 && op <= 0.05) { + hide(el, 'click-trap'); + return; + } + var gif = hasGifInside(el); + var topMost = z >= 2147480000 && hasAnyImage(el); + if (gif || topMost) { + hide(el, gif ? 'float-gif' : 'float-top'); + blockClass(el); + } + } + function scanFloating() { + var roots = [document.body, document.documentElement]; + for (var r = 0; r < roots.length; r++) { + var kids = roots[r] ? roots[r].children : []; + for (var i = 0; i < kids.length && i < 400; i++) checkFloating(kids[i]); + } + } + + function scan() { + scanFloating(); + var imgs = document.images; + for (var i = 0; i < imgs.length; i++) check(imgs[i]); + var frames = document.getElementsByTagName('iframe'); + for (var j = 0; j < frames.length; j++) check(frames[j]); + // 外链上的背景图(常见于 CSS 背景动图广告) + var links = document.querySelectorAll('a[href^="http"], a[href^="//"]'); + for (var k = 0; k < links.length && k < 400; k++) { + var a = links[k]; + if (a.__tvChecked || a.__tvFiltered) continue; + if (isExternal(a.getAttribute('href')) && bgImage(a)) check(a); + else a.__tvChecked = true; + } + } + + var timer = null; + function schedule() { + if (timer) return; + timer = setTimeout(function() { timer = null; scan(); }, 300); + } + window.__tvContentFilter = schedule; + // 4. 防跳转劫持:记录用户真正点击的链接(捕获阶段,早于页面脚本),原生层只放行与之匹配的跨站跳转 + function recordLink(e) { + try { + var t = e.target; + var a = t && t.closest ? t.closest('a[href]') : null; + var g = window.AndroidNavGuard; + if (g && g.onUserLink) g.onUserLink(a ? a.href : ''); + } catch (err) {} + } + window.addEventListener('click', recordLink, true); + scan(); + try { + new MutationObserver(schedule).observe(document.documentElement, { childList: true, subtree: true }); + } catch (e) {} + window.addEventListener('load', schedule); + } catch (e) {} + })(); + """.trimIndent() +} diff --git a/app/src/main/java/com/tv/browser/ContinueWatchingAdapter.kt b/app/src/main/java/com/tv/browser/ContinueWatchingAdapter.kt new file mode 100644 index 0000000..ce2dce9 --- /dev/null +++ b/app/src/main/java/com/tv/browser/ContinueWatchingAdapter.kt @@ -0,0 +1,65 @@ +package com.tv.browser + +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.tv.browser.databinding.ItemContinueBinding + +/** + * 首页追剧列表(继续观看):一部剧一张卡片。 + * 确定键 → 继续播放;长按确定 / 菜单键 → 移除。 + */ +class ContinueWatchingAdapter( + private val onPlay: (PlaybackRecord) -> Unit, + private val onRemove: (PlaybackRecord, Int) -> Unit +) : RecyclerView.Adapter() { + + private var items: List = emptyList() + + class VH(val b: ItemContinueBinding) : RecyclerView.ViewHolder(b.root) + + fun submit(list: List) { + items = list + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = + VH(ItemContinueBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + + override fun getItemCount(): Int = items.size + + override fun onBindViewHolder(holder: VH, position: Int) { + val r = items[position] + holder.b.continueTitle.text = r.displayTitle() + holder.b.continueSubtitle.text = r.displaySubtitle() + holder.b.continueProgress.progress = r.getProgressPercent() + holder.b.root.setOnClickListener { onPlay(r) } + holder.b.continueTitle.setMarqueeEnabled(holder.b.root.isFocused) + holder.b.root.setOnFocusChangeListener { view, hasFocus -> + val scale = if (hasFocus) 1.05f else 1.0f + view.animate().scaleX(scale).scaleY(scale).setDuration(150).start() + view.elevation = if (hasFocus) 6f else 0f + // 选中时标题滚动显示,未选中时省略号 + holder.b.continueTitle.setMarqueeEnabled(hasFocus) + } + holder.b.root.setOnLongClickListener { + onRemove(r, holder.bindingAdapterPosition) + true + } + holder.b.root.setOnKeyListener { _, keyCode, event -> + if (keyCode == KeyEvent.KEYCODE_MENU && event.action == KeyEvent.ACTION_UP) { + onRemove(r, holder.bindingAdapterPosition) + true + } else false + } + } + + override fun onViewRecycled(holder: VH) { + super.onViewRecycled(holder) + holder.b.root.scaleX = 1.0f + holder.b.root.scaleY = 1.0f + holder.b.root.elevation = 0f + holder.b.continueTitle.setMarqueeEnabled(false) + } +} diff --git a/app/src/main/java/com/tv/browser/HistoryAdapter.kt b/app/src/main/java/com/tv/browser/HistoryAdapter.kt new file mode 100644 index 0000000..0a44da2 --- /dev/null +++ b/app/src/main/java/com/tv/browser/HistoryAdapter.kt @@ -0,0 +1,41 @@ +package com.tv.browser + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.tv.browser.databinding.ItemHistoryBinding + +/** + * 历史记录列表适配器。 + * + * 使用 RecyclerView 而非 ListView:ListView 的「选中项(selection)」机制与可获焦子项混用时, + * 遥控器上下键会出现需要按两次、跳项、焦点卡住等不灵敏问题;RecyclerView 走标准焦点查找,响应稳定。 + */ +class HistoryAdapter( + private var list: List, + private val onOpen: (HistoryItem) -> Unit +) : RecyclerView.Adapter() { + + fun updateList(newList: List) { + list = newList + notifyDataSetChanged() + } + + fun getItem(position: Int): HistoryItem? = list.getOrNull(position) + + override fun getItemCount(): Int = list.size + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemHistoryBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = list[position] + holder.binding.historyTitle.text = item.displayTitle() + holder.binding.historyUrl.text = item.url + holder.binding.root.setOnClickListener { onOpen(item) } + } + + class ViewHolder(val binding: ItemHistoryBinding) : RecyclerView.ViewHolder(binding.root) +} diff --git a/app/src/main/java/com/tv/browser/HistoryDialog.kt b/app/src/main/java/com/tv/browser/HistoryDialog.kt new file mode 100644 index 0000000..64edc80 --- /dev/null +++ b/app/src/main/java/com/tv/browser/HistoryDialog.kt @@ -0,0 +1,92 @@ +package com.tv.browser + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Window +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.LinearLayoutManager +import com.tv.browser.databinding.DialogHistoryBinding + +/** + * 历史记录对话框:列表展示、点击打开、清空历史,适配 TV 焦点。 + */ +class HistoryDialog : DialogFragment() { + + var onOpenUrl: ((String) -> Unit)? = null + + private val repo: HistoryRepository by lazy { + HistoryRepository(requireContext()) + } + + private var binding: DialogHistoryBinding? = null + private var adapter: HistoryAdapter? = null + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + binding = DialogHistoryBinding.inflate(LayoutInflater.from(requireContext())) + val view = binding!!.root + + adapter = HistoryAdapter( + list = repo.getAll(), + onOpen = { item -> + onOpenUrl?.invoke(item.url) + dismiss() + } + ) + + binding!!.historyList.layoutManager = LinearLayoutManager(requireContext()) + binding!!.historyList.adapter = adapter + binding!!.btnClose.setOnClickListener { dismiss() } + binding!!.btnClearHistory.setOnClickListener { + repo.clear() + refreshList() + Toast.makeText(requireContext(), getString(R.string.history_cleared), Toast.LENGTH_SHORT).show() + } + + refreshList() + + val dialog = AlertDialog.Builder(requireContext()) + .setView(view) + .create() + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) + dialog.setOnShowListener { focusFirstItem() } + return dialog + } + + /** 弹窗显示后把焦点放到第一条记录上;列表为空时落到“关闭”按钮 */ + private fun focusFirstItem() { + val b = binding ?: return + val list = b.historyList + if ((adapter?.itemCount ?: 0) == 0) { + b.btnClose.requestFocus() + return + } + list.scrollToPosition(0) + list.post { + val first = list.findViewHolderForAdapterPosition(0)?.itemView + if (first == null || !first.requestFocus()) b.btnClose.requestFocus() + } + } + + override fun onStart() { + super.onStart() + (activity as? BrowserActivity)?.setCursorOverlayVisible(false, this) + } + + override fun onDestroyView() { + (activity as? BrowserActivity)?.setCursorOverlayVisible(true, this) + super.onDestroyView() + binding = null + } + + private fun refreshList() { + val list = repo.getAll() + adapter?.updateList(list) + binding?.let { b -> + b.historyList.visibility = if (list.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE + b.emptyHint.visibility = if (list.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE + } + } +} diff --git a/app/src/main/java/com/tv/browser/HistoryItem.kt b/app/src/main/java/com/tv/browser/HistoryItem.kt new file mode 100644 index 0000000..bb5cea3 --- /dev/null +++ b/app/src/main/java/com/tv/browser/HistoryItem.kt @@ -0,0 +1,12 @@ +package com.tv.browser + +/** + * 浏览历史项:URL、标题、访问时间。 + */ +data class HistoryItem( + val url: String, + val title: String, + val visitedAt: Long +) { + fun displayTitle(): String = if (title.isNotBlank()) title else url +} diff --git a/app/src/main/java/com/tv/browser/HistoryRepository.kt b/app/src/main/java/com/tv/browser/HistoryRepository.kt new file mode 100644 index 0000000..e18a587 --- /dev/null +++ b/app/src/main/java/com/tv/browser/HistoryRepository.kt @@ -0,0 +1,67 @@ +package com.tv.browser + +import android.content.Context +import android.content.SharedPreferences +import org.json.JSONArray +import org.json.JSONObject + +/** + * 浏览历史持久化,最多保留 [MAX_ENTRIES] 条。 + */ +class HistoryRepository(context: Context) { + + private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + 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) + ) + } + } catch (_: Exception) { + emptyList() + } + } + + /** 添加一条记录:同 URL 只保留最新,总数不超过 [MAX_ENTRIES] */ + fun add(url: String, title: String) { + if (url.isBlank() || url.startsWith("about:") || url.startsWith("data:")) return + val now = System.currentTimeMillis() + val list = getAll().toMutableList() + list.removeAll { it.url == url } + list.add(0, HistoryItem(url = url, title = title.trim(), visitedAt = now)) + save(list.take(MAX_ENTRIES)) + } + + fun clear() { + 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) + }) + } + prefs.edit().putString(keyHistory, arr.toString()).apply() + } + + companion object { + private const val PREFS_NAME = "tv_browser_history" + private const val KEY_URL = "url" + private const val KEY_TITLE = "title" + private const val KEY_VISITED_AT = "visitedAt" + const val MAX_ENTRIES = 50 + } +} diff --git a/app/src/main/java/com/tv/browser/HomeCardAdapter.kt b/app/src/main/java/com/tv/browser/HomeCardAdapter.kt new file mode 100644 index 0000000..0d7ba87 --- /dev/null +++ b/app/src/main/java/com/tv/browser/HomeCardAdapter.kt @@ -0,0 +1,149 @@ +package com.tv.browser + +import android.graphics.drawable.GradientDrawable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.RecyclerView +import com.tv.browser.databinding.ItemHomeCardBinding + +/** 徽章点缀颜色(科技感常用色) */ +private val BADGE_COLORS = intArrayOf( + 0xFF2563EB.toInt(), // 科技蓝 + 0xFF7C3AED.toInt(), // 优雅紫 + 0xFF059669.toInt(), // 祖母绿 + 0xFFD97706.toInt(), // 暖琥珀 + 0xFFDB2777.toInt(), // 霓虹粉 + 0xFF0891B2.toInt(), // 湖水蓝 + 0xFF4F46E5.toInt(), // 靛蓝 + 0xFFEA580C.toInt(), // 活力橙 +) + +/** + * 首页收藏卡片网格适配器:暗色大屏微质感,支持焦点放大、边框发光与徽章展示。 + */ +class HomeCardAdapter( + private var list: List, + private val onItemClick: (Bookmark) -> Unit, + private val onDelete: (Bookmark) -> Unit +) : RecyclerView.Adapter() { + + /** 是否处于卡片整理(编辑)模式:true 时所有卡片显示 X 删除标记,确认键点卡片即删除 */ + var isEditing: Boolean = false + private set + + fun setEditing(editing: Boolean) { + if (isEditing != editing) { + isEditing = editing + // 局部刷新:保留现有 ItemView,不会像 notifyDataSetChanged 那样丢失遥控器焦点 + notifyItemRangeChanged(0, itemCount) + } + } + + fun updateList(newList: List) { + list = newList + notifyDataSetChanged() + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val binding = ItemHomeCardBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return ViewHolder(binding, createNormalBg(), createFocusedBg()) + } + + private fun createNormalBg(): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = 16f + setColor(0xFF1E232E.toInt()) + setStroke(2, 0xFF2F3746.toInt()) + } + } + + private fun createFocusedBg(): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = 16f + setColor(0xFF283244.toInt()) + setStroke(3, 0xFF38BDF8.toInt()) // 荧光青蓝发光边框 + } + } + + private fun confirmDelete(holder: ViewHolder, item: Bookmark) { + AlertDialog.Builder(holder.itemView.context) + .setMessage("确定删除此收藏吗?") + .setPositiveButton(R.string.remove) { _, _ -> + onDelete(item) + } + .setNegativeButton(R.string.close, null) + .show() + } + + /** 根据焦点状态刷新卡片外观;不再在焦点变化时切换 ✕ 的显隐,避免焦点移动过程中布局抖动 */ + private fun applyFocusStyle(holder: ViewHolder, focused: Boolean, animate: Boolean) { + val v = holder.itemView + v.background = if (focused) holder.focusedBg else holder.normalBg + holder.url.setTextColor(if (focused) 0xFF38BDF8.toInt() else 0xFF94A3B8.toInt()) + val scale = if (focused) 1.08f else 1.0f + val z = if (focused) 16f else 0f + if (animate) { + v.animate().scaleX(scale).scaleY(scale).translationZ(z).setDuration(120).start() + } else { + v.animate().cancel() + v.scaleX = scale + v.scaleY = scale + v.translationZ = z + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = list[position] + holder.title.text = item.displayTitle() + holder.url.text = item.displayHost() + + // 提取首字符作为精美圆形徽章 + val initial = item.displayTitle().trim().take(1).ifBlank { "★" } + holder.badge.text = initial + holder.badge.background = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(BADGE_COLORS[position % BADGE_COLORS.size]) + } + + // 点击卡片:整理模式下删除,否则打开网址 + holder.itemView.setOnClickListener { + if (isEditing) confirmDelete(holder, item) else onItemClick(item) + } + + // 焦点动效:获取焦点时放大 1.08 倍并提亮边框,失去焦点时平滑缩回 + holder.itemView.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> + applyFocusStyle(holder, hasFocus, animate = true) + } + + // 初始化背景与状态 + applyFocusStyle(holder, holder.itemView.hasFocus(), animate = false) + holder.delete.visibility = if (isEditing) View.VISIBLE else View.GONE + + // ✕ 不可获焦,仅供触摸/鼠标点击;遥控器在整理模式下直接按确认键删除 + holder.delete.setOnClickListener { confirmDelete(holder, item) } + } + + override fun onViewRecycled(holder: ViewHolder) { + holder.itemView.animate().cancel() + super.onViewRecycled(holder) + } + + override fun getItemCount(): Int = list.size + + class ViewHolder( + binding: ItemHomeCardBinding, + /** 每个卡片持有自己的背景实例(Drawable 不能跨 View 共享),避免每次焦点变化都新建对象 */ + val normalBg: GradientDrawable, + val focusedBg: GradientDrawable + ) : RecyclerView.ViewHolder(binding.root) { + val badge: TextView = binding.homeCardBadge + val title: TextView = binding.homeCardTitle + val url: TextView = binding.homeCardUrl + val delete: TextView = binding.homeCardDelete + } +} diff --git a/app/src/main/java/com/tv/browser/MarqueeHelper.kt b/app/src/main/java/com/tv/browser/MarqueeHelper.kt new file mode 100644 index 0000000..250698d --- /dev/null +++ b/app/src/main/java/com/tv/browser/MarqueeHelper.kt @@ -0,0 +1,22 @@ +package com.tv.browser + +import android.text.TextUtils +import android.widget.TextView + +/** + * 单行标题的"选中滚动 / 未选中省略号"切换。 + * + * TextView 的跑马灯需要 singleLine + ellipsize=MARQUEE + isSelected=true, + * 但 MARQUEE 模式下未滚动时不会显示省略号,所以按焦点状态动态切换 ellipsize。 + */ +fun TextView.setMarqueeEnabled(enabled: Boolean) { + if (enabled) { + if (ellipsize != TextUtils.TruncateAt.MARQUEE) ellipsize = TextUtils.TruncateAt.MARQUEE + marqueeRepeatLimit = -1 // 无限循环 + isSelected = false + isSelected = true + } else { + isSelected = false + if (ellipsize != TextUtils.TruncateAt.END) ellipsize = TextUtils.TruncateAt.END + } +} diff --git a/app/src/main/java/com/tv/browser/NetworkChecker.kt b/app/src/main/java/com/tv/browser/NetworkChecker.kt new file mode 100644 index 0000000..7da7c30 --- /dev/null +++ b/app/src/main/java/com/tv/browser/NetworkChecker.kt @@ -0,0 +1,128 @@ +package com.tv.browser + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import java.net.HttpURLConnection +import java.net.Inet4Address +import java.net.NetworkInterface +import java.net.URL + +/** + * 检查网络连接状态、系统验证结果与互联网实际可达性。 + */ +object NetworkChecker { + + private const val INTERNET_CHECK_URL = "https://www.baidu.com" + private const val INTERNET_CHECK_TIMEOUT_MS = 8000 + + /** + * 返回链路状态文案:已连接时区分「系统已验证互联网」与「未验证」。 + */ + fun getStatusMessage(context: Context): String { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return context.getString(R.string.network_disconnected) + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = cm.activeNetwork ?: return context.getString(R.string.network_disconnected) + val caps = cm.getNetworkCapabilities(network) ?: return context.getString(R.string.network_disconnected) + val typeName = when { + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> + context.getString(R.string.network_type_wifi) + caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> + context.getString(R.string.network_type_ethernet) + else -> context.getString(R.string.network_type_unknown) + } + val validated = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + if (validated) { + context.getString(R.string.network_connected, typeName) + } else { + context.getString(R.string.network_connected_not_validated, typeName) + } + } else { + @Suppress("DEPRECATION") + val info = cm.activeNetworkInfo + if (info?.isConnected == true) { + val typeName = when (info.type) { + ConnectivityManager.TYPE_WIFI -> context.getString(R.string.network_type_wifi) + ConnectivityManager.TYPE_ETHERNET -> context.getString(R.string.network_type_ethernet) + else -> context.getString(R.string.network_type_unknown) + } + context.getString(R.string.network_connected, typeName) + } else { + context.getString(R.string.network_disconnected) + } + } + } + + fun isConnected(context: Context): Boolean { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return false + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } else { + @Suppress("DEPRECATION") + cm.activeNetworkInfo?.isConnected == true + } + } + + /** + * 实际尝试访问互联网(需在后台线程调用)。 + * @return true 表示能连上目标 URL,false 表示超时或失败。 + */ + fun isInternetReachable(): Boolean { + return try { + val url = URL(INTERNET_CHECK_URL) + val conn = url.openConnection() as? HttpURLConnection ?: return false + if (conn is javax.net.ssl.HttpsURLConnection) { + conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } + val trustAll = arrayOf( + object : javax.net.ssl.X509TrustManager { + override fun getAcceptedIssuers(): Array? = null + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + } + ) + val sc = javax.net.ssl.SSLContext.getInstance("TLS") + sc.init(null, trustAll, java.security.SecureRandom()) + conn.sslSocketFactory = sc.socketFactory + } + conn.requestMethod = "HEAD" + conn.connectTimeout = INTERNET_CHECK_TIMEOUT_MS + conn.readTimeout = INTERNET_CHECK_TIMEOUT_MS + conn.instanceFollowRedirects = true + val code = conn.responseCode + conn.disconnect() + code in 200..399 + } catch (_: Exception) { + false + } + } + + /** + * 获取局域网 IP 地址(优先 Wi-Fi / 有线) + */ + fun getLocalIpAddress(): String? { + try { + val interfaces = NetworkInterface.getNetworkInterfaces()?.toList() ?: return null + val sorted = interfaces.sortedWith { a, b -> + val aScore = if (a.name.startsWith("wlan") || a.name.startsWith("eth")) 0 else 1 + val bScore = if (b.name.startsWith("wlan") || b.name.startsWith("eth")) 0 else 1 + aScore.compareTo(bScore) + } + for (ni in sorted) { + if (ni.isLoopback || !ni.isUp) continue + for (addr in ni.inetAddresses) { + if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.hostAddress.orEmpty().startsWith("127.")) { + return addr.hostAddress + } + } + } + } catch (_: Exception) { } + return null + } +} diff --git a/app/src/main/java/com/tv/browser/PlaybackHistoryRepository.kt b/app/src/main/java/com/tv/browser/PlaybackHistoryRepository.kt new file mode 100644 index 0000000..1719dd0 --- /dev/null +++ b/app/src/main/java/com/tv/browser/PlaybackHistoryRepository.kt @@ -0,0 +1,189 @@ +package com.tv.browser + +import android.content.Context +import android.content.SharedPreferences +import org.json.JSONArray +import org.json.JSONObject + +/** + * 视频播放记录项:用于断点续播与继续观看推荐 + */ +data class PlaybackRecord( + val url: String, + val title: String, + val positionMs: Int, + val durationMs: Int, + val timestamp: Long = System.currentTimeMillis(), + /** 剧集分组 key(站点主域|剧名);同一部剧只保留最近一集的记录。空表示单个视频 */ + val series: String = "", + /** 剧名(如「沧元图」) */ + val seriesTitle: String = "", + /** 集名(如「第03集」) */ + val episodeName: String = "" +) { + fun getProgressPercent(): Int { + if (durationMs <= 0) return 0 + return ((positionMs.toLong() * 100) / durationMs).toInt().coerceIn(0, 100) + } + + fun formatPosition(): String = formatMillis(positionMs) + fun formatDuration(): String = formatMillis(durationMs) + + /** 首页卡片主标题:优先剧名 */ + fun displayTitle(): String = seriesTitle.ifBlank { title } + + /** 首页卡片副标题:集名 · 进度(极简短文案,适配单行横向排版) */ + fun displaySubtitle(): String { + val ep = episodeName.ifBlank { if (seriesTitle.isNotBlank() && title != seriesTitle) title.removePrefix(seriesTitle).trim() else "" } + val progress = when { + positionMs <= 5000 -> "待播" + durationMs > 0 -> "${getProgressPercent()}%" + else -> formatPosition() + } + return if (ep.isBlank()) progress else "$ep · $progress" + } + + private fun formatMillis(millis: Int): String { + if (millis <= 0) return "00:00" + val totalSec = millis / 1000 + val sec = totalSec % 60 + val min = (totalSec / 60) % 60 + val hour = totalSec / 3600 + return if (hour > 0) { + String.format("%02d:%02d:%02d", hour, min, sec) + } else { + String.format("%02d:%02d", min, sec) + } + } +} + +/** + * 播放历史持久化管理(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() + } + } + + /** + * 保存播放进度:若播放时间超过 5 秒且未播完,则保存或更新。 + * 带 [series] 时同一部剧只保留这一条(追剧列表里一部剧一张卡片)。 + */ + fun saveProgress( + url: String, + title: String, + positionMs: Int, + durationMs: Int, + series: String = "", + seriesTitle: String = "", + episodeName: String = "" + ) { + if (url.isBlank() || positionMs < 5000) return + val list = getAll().toMutableList() + list.removeAll { it.url == url || (series.isNotBlank() && it.series == series) } + + // 如果已经播到最后 10 秒,视为播放结束,不再存为断点 + if (durationMs > 0 && positionMs >= durationMs - 10000) { + save(list) + return + } + + val record = PlaybackRecord( + url = url, + title = title.ifBlank { "在线视频" }, + positionMs = positionMs, + durationMs = durationMs, + timestamp = System.currentTimeMillis(), + series = series, + seriesTitle = seriesTitle, + episodeName = episodeName + ) + list.add(0, record) + while (list.size > MAX_RECORDS) list.removeAt(list.size - 1) + save(list) + } + + /** + * 一集看完后记下"下一集待看"(进度 0),追剧列表里这部剧会显示为下一集,点开从头播。 + */ + fun markUpNext(url: String, title: String, series: String, seriesTitle: String, episodeName: String) { + if (url.isBlank() || series.isBlank()) return + val list = getAll().toMutableList() + list.removeAll { it.url == url || it.series == series } + list.add(0, PlaybackRecord(url, title.ifBlank { seriesTitle }, 0, 0, System.currentTimeMillis(), series, seriesTitle, episodeName)) + while (list.size > MAX_RECORDS) list.removeAt(list.size - 1) + save(list) + } + + /** 获取最近一条未看完的播放记录 */ + fun getLastRecord(): PlaybackRecord? = getRecent(1).firstOrNull() + + /** 最近未看完的记录(追剧列表):有进度的,或"下一集待看"的剧集 */ + fun getRecent(limit: Int): List = + getAll().filter { isUnfinished(it) }.take(limit) + + private fun isUnfinished(r: PlaybackRecord): Boolean { + if (r.positionMs <= 5000) return r.series.isNotBlank() + return r.durationMs <= 0 || r.positionMs < r.durationMs - 10000 + } + + /** 根据播放地址查询历史记录 */ + fun getRecordForUrl(url: String): PlaybackRecord? { + if (url.isBlank()) return null + return getAll().firstOrNull { it.url == url } + } + + fun remove(url: String) { + val list = getAll().toMutableList() + list.removeAll { it.url == url } + save(list) + } + + fun clearAll() { + 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) + }) + } + 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 + } +} diff --git a/app/src/main/java/com/tv/browser/PlayerActivity.kt b/app/src/main/java/com/tv/browser/PlayerActivity.kt new file mode 100644 index 0000000..bc84b1e --- /dev/null +++ b/app/src/main/java/com/tv/browser/PlayerActivity.kt @@ -0,0 +1,1579 @@ +package com.tv.browser + +import android.app.PictureInPictureParams +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.res.Configuration +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.util.Rational +import android.view.KeyEvent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.SeekBar +import android.widget.Toast +import androidx.annotation.OptIn +import androidx.appcompat.app.AppCompatActivity +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.ui.AspectRatioFrameLayout +import android.net.TrafficStats +import android.os.Process +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.mediacodec.MediaCodecSelector +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import java.util.Calendar +import com.tv.browser.databinding.ActivityPlayerBinding +import com.tv.browser.databinding.ItemEpisodeBinding +import java.util.Locale +import kotlin.math.ceil +import kotlin.math.min + +/** + * 电视端专属全屏原生播放器(Media3 ExoPlayer): + * 1. 播放 HLS(.m3u8) / MP4 / FLV 等,原样带上网页的请求头(Referer / UA / Cookie),解决 403 + * 2. 遥控器 D-pad 全键位适配:无浮层时左右快进快退(长按加速)、确定键暂停、上键选集、下键/菜单键呼出控制栏 + * 3. 选集面板 + 自动下一集:通过后台隐藏 WebView(VideoResolver)解析其它剧集的真实地址 + * 4. 沉浸式 OSD(进度、倍速、画面比例、画中画)、5 秒无操作自动隐藏 + * 5. 播放历史断点续播(以剧集页地址为 key,视频签名地址过期也不影响续播) + * 6. 一键调用外部播放器(Kodi / MX Player / VLC 等,附带请求头) + */ +@OptIn(UnstableApi::class) +class PlayerActivity : AppCompatActivity() { + + private lateinit var binding: ActivityPlayerBinding + private lateinit var historyRepo: PlaybackHistoryRepository + private var player: ExoPlayer? = null + private var resolver: VideoResolver? = null + + private var currentSource: VideoSource? = null + /** 当前剧集的网页地址(播放页) */ + private var currentPageUrl: String? = null + private var videoTitle: String = "" + private var seriesTitle: String = "" + private var episodes: List = emptyList() + private var currentEpisodeIndex = -1 + /** 播放线路(同一部剧的不同资源),只有一条时为空 */ + private var sources: List = emptyList() + private var currentSourceIndex = -1 + /** 本集已尝试过(失败)的线路,用于失败自动换线 */ + private val failedSources = mutableSetOf() + /** 换线后从这个位置继续播(不同线路的剧集页地址不同,历史进度对不上) */ + private var pendingStartPos = -1L + /** 最近一次正常播放到的位置(换线时沿用;播放出错后 player 位置可能已归零) */ + private var lastKnownPos = 0L + /** 播放失败后是否已自动重新解析过一次(签名地址过期时有用) */ + private var retriedResolve = false + /** 上次自动重新解析的时间,以及之后是否已恢复正常播放:分片签名定时过期的片源,播一段时间后允许再次重新解析 */ + private var reResolveAt = 0L + private var readySinceReResolve = false + /** 卡顿原因诊断(片源下载速度 / 视频所需码率 / 分片失败) */ + private val diagnostics = PlaybackDiagnostics() + /** 当前状态提示是否是卡顿诊断(恢复播放时收起) */ + private var stallHintShown = false + private var isSeeking = false + private var lastSavedSec = 0L + /** 本集已播完 / 已触发片尾跳转:之后不再保存进度(避免覆盖"下一集待看"记录) */ + private var suppressSave = false + /** 当前片源是否已做过片头检查 / 已触发片尾 */ + private var introChecked = false + private var outroTriggered = false + + /** 下一集预解析:独立的后台解析器(VideoResolver 一个实例同时只能解析一个页面) */ + private var preloadResolver: VideoResolver? = null + private var preloadingIndex = -1 + private var preloaded: PreloadedEpisode? = null + + private data class PreloadedEpisode(val index: Int, val pageUrl: String, val source: VideoSource, val time: Long) + + private val mainHandler = Handler(Looper.getMainLooper()) + private val speeds = floatArrayOf(1.0f, 1.25f, 1.5f, 2.0f, 0.75f) + private var currentSpeedIndex = 0 + + private val ratioNames = arrayOf("适应屏幕", "拉伸铺满", "裁剪填充") + private val ratioModes = intArrayOf( + AspectRatioFrameLayout.RESIZE_MODE_FIT, + AspectRatioFrameLayout.RESIZE_MODE_FILL, + AspectRatioFrameLayout.RESIZE_MODE_ZOOM + ) + private var currentRatioIndex = 0 + + private val episodeAdapter = EpisodeAdapter() + + // OSD 自动隐藏延迟 5 秒 + private val hideOsdRunnable = Runnable { hideOsd() } + + // 进度条每 500ms 刷新 + private val updateProgressRunnable = object : Runnable { + override fun run() { + if (!isSeeking) updateProgress() + mainHandler.postDelayed(this, 500) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // 沉浸式全屏 + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN or WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + hideSystemUI() + + binding = ActivityPlayerBinding.inflate(layoutInflater) + setContentView(binding.root) + + historyRepo = PlaybackHistoryRepository(this) + + val rawUrl = intent.getStringExtra(EXTRA_VIDEO_URL).orEmpty() + val pageUrlExtra = intent.getStringExtra(EXTRA_PAGE_URL)?.takeIf { it.isNotBlank() } + videoTitle = intent.getStringExtra(EXTRA_VIDEO_TITLE).orEmpty().ifBlank { "原生播放器" } + seriesTitle = intent.getStringExtra(EXTRA_SERIES_TITLE).orEmpty() + val seriesToken = intent.getStringExtra(EXTRA_SERIES_TOKEN) + val payload = getSeriesPayload(seriesToken) + // 有令牌但内存数据已丢失(进程被回收后重建):需要重新解析网页拿选集 + val payloadLost = seriesToken != null && payload == null + episodes = payload?.episodes.orEmpty() + sources = payload?.sources?.takeIf { it.size > 1 }.orEmpty() + currentSourceIndex = intent.getIntExtra(EXTRA_SOURCE_INDEX, -1) + val headers = VideoSource.headersFromBundle(intent.getBundleExtra(EXTRA_HEADERS)) + val isHls = intent.getBooleanExtra(EXTRA_IS_HLS, rawUrl.contains(".m3u8", ignoreCase = true)) + + // 从画中画恢复但系统新建了实例(未复用小窗):沿用小窗的播放位置,并关闭旧小窗 + if (intent.getBooleanExtra(EXTRA_RESTORE_FROM_PIP, false)) { + pendingStartPos = intent.getLongExtra(EXTRA_START_POSITION, -1L) + pipInstanceRef?.get()?.takeIf { it !== this }?.finish() + pipInstanceRef = null + } + + initPlayer() + initViews() + resolver = VideoResolver(this, binding.resolverHost, BrowserActivity.currentUserAgent(this)) + + when { + // 0)选集数据丢失:用来源页重新解析(同时恢复选集) + payloadLost && pageUrlExtra != null -> + resolveAndPlay(pageUrlExtra, needSiteInfo = true, status = "正在解析视频…") + // 1)已经有视频地址(浏览器嗅探到的) + rawUrl.isNotBlank() && (pageUrlExtra != null || !looksLikeWebPage(rawUrl)) -> { + currentPageUrl = pageUrlExtra + updateEpisodeIndex() + playSource(VideoSource(rawUrl, videoTitle, pageUrlExtra, headers, isHls)) + // 若未携带选集信息但存在来源网页,后台异步补全选集 + if (episodes.isEmpty() && pageUrlExtra != null) { + resolver?.resolve(pageUrlExtra, needSiteInfo = true, object : VideoResolver.Callback { + override fun onResolved(source: VideoSource, siteInfo: SiteInfo?) { + if (siteInfo != null && siteInfo.episodes.isNotEmpty() && episodes.isEmpty()) { + episodes = siteInfo.episodes + if (seriesTitle.isBlank()) seriesTitle = siteInfo.title + if (siteInfo.sources.size > 1 && sources.isEmpty()) { + sources = siteInfo.sources + currentSourceIndex = siteInfo.sourceIndex + } + updateEpisodeIndex() + updateEpisodeUi() + } + } + override fun onFailed(reason: String) { } + }) + } + } + // 2)历史记录里保存的是剧集网页地址:先后台解析 + rawUrl.isNotBlank() -> resolveAndPlay(rawUrl, needSiteInfo = episodes.isEmpty(), status = "正在解析视频…") + // 3)只给了网页地址 + pageUrlExtra != null -> resolveAndPlay(pageUrlExtra, needSiteInfo = episodes.isEmpty(), status = "正在解析视频…") + else -> { + Toast.makeText(this, "未检测到有效视频地址", Toast.LENGTH_SHORT).show() + finish() + } + } + } + + private fun looksLikeWebPage(url: String): Boolean = + (url.startsWith("http://") || url.startsWith("https://")) && !VideoSnifferHelper.isVideoStreamUrl(url) + + private fun hideSystemUI() { + @Suppress("DEPRECATION") + 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 + ) + } + + private fun createRenderersFactory(): DefaultRenderersFactory { + val mode = AppSettings.getDecoderMode(this) + val factory = DefaultRenderersFactory(this).apply { + setEnableDecoderFallback(true) + setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) + } + when (mode) { + AppSettings.DECODER_SOFTWARE -> { + factory.setMediaCodecSelector { mimeType, requiresSecure, requiresTunneling -> + val list = MediaCodecSelector.DEFAULT.getDecoderInfos(mimeType, requiresSecure, requiresTunneling) + list.sortedByDescending { + it.softwareOnly || + it.name.startsWith("c2.android.", ignoreCase = true) || + it.name.startsWith("OMX.google.", ignoreCase = true) + } + } + } + AppSettings.DECODER_HARDWARE -> { + factory.setMediaCodecSelector { mimeType, requiresSecure, requiresTunneling -> + val list = MediaCodecSelector.DEFAULT.getDecoderInfos(mimeType, requiresSecure, requiresTunneling) + list.sortedByDescending { it.hardwareAccelerated } + } + } + else -> { + // 默认自动:优先系统硬件,遇到错误由 setEnableDecoderFallback(true) 自动降级软解 + } + } + return factory + } + + private fun initPlayer() { + val p = ExoPlayer.Builder(this, createRenderersFactory()) + .setLoadControl(PlayerTuning.buildLoadControl()) + .build() + p.addAnalyticsListener(diagnostics) + binding.playerView.player = p + p.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(state: Int) { + when (state) { + Player.STATE_BUFFERING -> { + binding.playerLoading.visibility = View.VISIBLE + updatePauseOverlay() + } + Player.STATE_READY -> { + binding.playerLoading.visibility = View.GONE + binding.tvStatus.visibility = View.GONE + stallHintShown = false + readySinceReResolve = true + updateProgress() + maybeSkipIntro() + failedSources.clear() + updatePauseOverlay() + } + Player.STATE_ENDED -> { + updatePauseOverlay() + onPlaybackEnded() + } + else -> updatePauseOverlay() + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + updatePlayPauseButtonText() + updatePauseOverlay() + if (isPlaying && isOsdVisible()) { + mainHandler.removeCallbacks(hideOsdRunnable) + mainHandler.postDelayed(hideOsdRunnable, 5000) + } + } + + override fun onPlayerError(error: PlaybackException) { + handlePlayerError(error) + } + }) + player = p + } + + private fun initViews() { + binding.tvVideoTitle.text = videoTitle + + binding.btnBack.setOnClickListener { finish() } + binding.btnExternal.setOnClickListener { launchExternalPlayer() } + + binding.pauseOverlay.setOnClickListener { togglePlayPause() } + binding.btnPlayPause.setOnClickListener { togglePlayPause() } + binding.btnNext.setOnClickListener { playNextEpisode() } + binding.btnEpisodes.setOnClickListener { showEpisodePanel() } + binding.btnSource.setOnClickListener { showSourceDialog() } + binding.btnSpeed.setOnClickListener { togglePlaybackSpeed() } + binding.btnRatio.setOnClickListener { toggleAspectRatio() } + binding.btnDecode.setOnClickListener { showDecoderDialog() } + binding.btnSkip.setOnClickListener { showSkipDialog() } + binding.btnStats.setOnClickListener { showStatsDialog() } + binding.btnPip.setOnClickListener { enterPipMode() } + + binding.playerSeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { + if (fromUser) { + val duration = player?.duration ?: 0L + if (duration > 0) { + binding.tvCurrentTime.text = formatTime(progress.toLong() * duration / 1000) + } + showOsdTemporarily() + } + } + + override fun onStartTrackingTouch(seekBar: SeekBar?) { + isSeeking = true + } + + override fun onStopTrackingTouch(seekBar: SeekBar?) { + isSeeking = false + val p = player ?: return + val duration = p.duration + if (duration > 0) { + p.seekTo((seekBar?.progress ?: 0).toLong() * duration / 1000) + } + showOsdTemporarily() + } + }) + + // 点击屏幕切换 OSD 显示 + binding.playerRoot.setOnClickListener { + when { + isEpisodePanelVisible() -> hideEpisodePanel() + binding.playerOsd.visibility == View.VISIBLE -> hideOsd() + else -> showOsdTemporarily() + } + } + + // 选集网格 + binding.episodeGrid.layoutManager = GridLayoutManager(this, episodeSpan) + binding.episodeGrid.adapter = episodeAdapter + binding.episodeGrid.itemAnimator = null + + updateEpisodeUi() + showOsdTemporarily() + } + + // --------------------------------------------------------------------------------------- + // 播放 + // --------------------------------------------------------------------------------------- + + private fun historyKey(): String = currentPageUrl ?: currentSource?.url.orEmpty() + + /** 选集列数:电视(960dp 宽)约 8 列,手机竖屏约 4 列 */ + private val episodeSpan: Int + get() = (resources.configuration.screenWidthDp / EPISODE_CELL_DP).coerceIn(4, 10) + + private fun playSource(source: VideoSource) { + val p = player ?: return + currentSource = source + if (source.title.isNotBlank()) videoTitle = source.title + binding.tvVideoTitle.text = videoTitle + binding.tvStatus.visibility = View.GONE + binding.playerLoading.visibility = View.VISIBLE + + val ua = source.headers.entries.firstOrNull { it.key.equals("User-Agent", ignoreCase = true) }?.value + ?: BrowserActivity.currentUserAgent(this) + val props = source.headers.filterKeys { !it.equals("User-Agent", ignoreCase = true) } + val httpFactory = DefaultHttpDataSource.Factory() + .setUserAgent(ua) + .setDefaultRequestProperties(props) + .setAllowCrossProtocolRedirects(true) + .setConnectTimeoutMs(PlayerTuning.CONNECT_TIMEOUT_MS) + .setReadTimeoutMs(PlayerTuning.READ_TIMEOUT_MS) + // 清单命中缓存时直接用网页下载过的内容(一次性 m3u8 链接再请求会失效),分片等仍走网络 + val dataSourceFactory = PlaylistCacheDataSource.Factory(DefaultDataSource.Factory(this, httpFactory)) + Log.d(TAG, "playlist cached=${PlaylistCache.contains(source.url)}") + + val itemBuilder = MediaItem.Builder().setUri(source.url) + if (source.isHls) itemBuilder.setMimeType(MimeTypes.APPLICATION_M3U8) + val mediaSource = DefaultMediaSourceFactory(dataSourceFactory) + .setLoadErrorHandlingPolicy(PlayerTuning.ErrorPolicy()) + .createMediaSource(itemBuilder.build()) + diagnostics.reset() + stallHintShown = false + + // 断点续播:若此前有超过 5 秒记录则自动恢复进度;刚换线则沿用换线前的位置 + val record = historyRepo.getRecordForUrl(historyKey()) + val carried = pendingStartPos + pendingStartPos = -1L + val startPos = when { + carried > 0 -> carried + record != null && record.positionMs > 5000 -> record.positionMs.toLong() + else -> 0L + } + p.setMediaSource(mediaSource, startPos) + p.setPlaybackSpeed(speeds[currentSpeedIndex]) + p.prepare() + p.playWhenReady = true + if (carried <= 0 && record != null && startPos > 0) showFeedback("已自动续播至 ${record.formatPosition()}") + + Log.d(TAG, "play: ${source.url} hls=${source.isHls} headers=${source.headers.keys}") + lastSavedSec = 0 + suppressSave = false + introChecked = false + outroTriggered = false + mainHandler.removeCallbacks(updateProgressRunnable) + mainHandler.post(updateProgressRunnable) + updateEpisodeUi() + showOsdTemporarily() + } + + /** 后台解析剧集页拿到真实地址后播放 */ + private fun resolveAndPlay(pageUrl: String, needSiteInfo: Boolean, status: String) { + player?.stop() + // 解析期间清空当前片源:防止进度定时器把上一集的进度存到新剧集地址下 + currentSource = null + currentPageUrl = pageUrl + updateEpisodeIndex() + binding.playerLoading.visibility = View.VISIBLE + showStatus(status) + resolver?.resolve(pageUrl, needSiteInfo, object : VideoResolver.Callback { + override fun onResolved(source: VideoSource, siteInfo: SiteInfo?) { + if (siteInfo != null && siteInfo.episodes.isNotEmpty() && episodes.isEmpty()) { + episodes = siteInfo.episodes + seriesTitle = siteInfo.title + } + if (siteInfo != null && siteInfo.sources.size > 1 && sources.isEmpty()) { + sources = siteInfo.sources + currentSourceIndex = siteInfo.sourceIndex + } + updateEpisodeIndex() + playSource(source) + } + + override fun onFailed(reason: String) { + if (tryFailover("解析失败:$reason")) return + binding.playerLoading.visibility = View.GONE + showStatus("解析失败:$reason") + showOsdTemporarily() + } + }) + } + + private fun handlePlayerError(error: PlaybackException) { + binding.playerLoading.visibility = View.GONE + val cause = PlayerSsl.rootCauseText(error) + Log.e(TAG, "playback error: ${error.errorCodeName} cause=$cause", error) + val page = currentPageUrl + // 证书 / SSL 握手失败:网页会忽略证书错误继续播,原生播放器这里同样放宽后重试一次 + if (PlayerSsl.isSslError(error) && PlayerSsl.enableLenient()) { + if (page != null) { + resolveAndPlay(page, needSiteInfo = false, status = "证书校验失败,已切换兼容模式重试…") + } else { + currentSource?.let { playSource(it) } + } + return + } + if (page != null && (!retriedResolve || (readySinceReResolve && android.os.SystemClock.elapsedRealtime() - reResolveAt > 60_000L))) { + // 常见原因:签名地址过期 / 防盗链 → 重新解析拿新地址,从当前位置接着播。 + // 分片签名定时过期的片源会「播一段时间就失败」:上次重新解析后已正常播放超过 1 分钟,允许再次重新解析 + retriedResolve = true + reResolveAt = android.os.SystemClock.elapsedRealtime() + readySinceReResolve = false + if (lastKnownPos > 5000) pendingStartPos = lastKnownPos + resolveAndPlay(page, needSiteInfo = false, status = "播放失败,正在重新解析…") + return + } + // 当前线路确实不行:自动换到下一条线路的同一集 + if (tryFailover("播放失败(${error.errorCodeName})")) return + val detail = if (cause.isNotBlank()) "\n$cause" else "" + val hint = if (sources.size > 1) "可尝试「线路」或「外部播放」" else "可尝试「外部播放」" + showStatus("播放失败(${error.errorCodeName}),$hint$detail") + showOsdTemporarily() + binding.btnExternal.requestFocus() + } + + private fun onPlaybackEnded() { + suppressSave = true + outroTriggered = true + historyRepo.remove(historyKey()) + if (hasNextEpisode()) { + // 追剧列表:这部剧显示为下一集「待播放」 + val next = episodes[currentEpisodeIndex + 1] + historyRepo.markUpNext(next.url, episodeTitle(next), currentSeriesKey(), seriesTitle, next.name) + showFeedback("即将播放下一集") + playNextEpisode() + } else { + updatePlayPauseButtonText() + showOsdTemporarily() + } + } + + // --------------------------------------------------------------------------------------- + // 选集 + // --------------------------------------------------------------------------------------- + + private fun updateEpisodeIndex() { + val page = currentPageUrl + currentEpisodeIndex = if (page == null) -1 else { + val key = SiteInfo.normalizeUrl(page) + episodes.indexOfFirst { SiteInfo.normalizeUrl(it.url) == key } + } + } + + private fun hasNextEpisode(): Boolean = + currentEpisodeIndex >= 0 && currentEpisodeIndex < episodes.size - 1 + + private fun playNextEpisode() { + if (hasNextEpisode()) playEpisode(currentEpisodeIndex + 1) + else showFeedback("已经是最后一集") + } + + private fun playPrevEpisode() { + if (currentEpisodeIndex > 0) playEpisode(currentEpisodeIndex - 1) + else showFeedback("已经是第一集") + } + + private fun playEpisode(index: Int) { + if (index !in episodes.indices) return + saveCurrentProgress() + hideEpisodePanel() + currentEpisodeIndex = index + retriedResolve = false + lastKnownPos = 0L + pendingStartPos = -1L + failedSources.clear() + val ep = episodes[index] + videoTitle = episodeTitle(ep) + binding.tvVideoTitle.text = videoTitle + updateEpisodeUi() + + // 提前解析好的下一集(10 分钟内有效,签名地址太久会过期):直接播放,免等待 + val pre = preloaded + preloaded = null + if (pre != null && pre.index == index && pre.pageUrl == ep.url && + System.currentTimeMillis() - pre.time < PRELOAD_VALID_MS + ) { + Log.d(TAG, "play preloaded episode #$index ${ep.name}") + player?.stop() + currentPageUrl = ep.url + updateEpisodeIndex() + playSource(pre.source.copy(title = videoTitle)) + return + } + cancelPreload() + resolveAndPlay(ep.url, needSiteInfo = false, status = "正在解析:${ep.name}…") + } + + private fun episodeTitle(ep: Episode): String = + if (seriesTitle.isNotBlank() && !ep.name.contains(seriesTitle)) "$seriesTitle ${ep.name}" else ep.name + + /** 追剧分组 key:站点主域|剧名 */ + private fun currentSeriesKey(): String = AppSettings.seriesKey(currentPageUrl, seriesTitle) + + /** 片头片尾记忆 key:有剧名按剧记,否则按单个视频标题记 */ + private fun skipKey(): String = + AppSettings.seriesKey(currentPageUrl ?: currentSource?.url, seriesTitle.ifBlank { videoTitle }) + + // --------------------------------------------------------------------------------------- + // 线路切换 / 失败自动换线 + // --------------------------------------------------------------------------------------- + + /** 当前剧集页属于哪条线路(站点没给出时按地址查找) */ + private fun syncSourceIndex() { + if (sources.size <= 1) return + val page = currentPageUrl?.let { SiteInfo.normalizeUrl(it) } ?: return + val idx = sources.indexOfFirst { s -> s.episodes.any { SiteInfo.normalizeUrl(it.url) == page } } + if (idx >= 0) currentSourceIndex = idx + } + + private fun currentSourceName(): String = sources.getOrNull(currentSourceIndex)?.name.orEmpty() + + private fun showSourceDialog() { + if (sources.size <= 1) return + mainHandler.removeCallbacks(hideOsdRunnable) + val epName = episodes.getOrNull(currentEpisodeIndex)?.name + val items = sources.mapIndexed { i, s -> + val mark = when { + i == currentSourceIndex -> "▶ " + i in failedSources -> "✕ " + else -> " " + } + val has = if (epName != null && findEpisodeIn(s) == null) "(无此集)" else "(${s.episodes.size} 集)" + "$mark${s.name} $has" + }.toTypedArray() + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle("切换线路" + if (epName != null) " · 当前:$epName" else "") + .setSingleChoiceItems(items, currentSourceIndex) { dialog, which -> + dialog.dismiss() + if (which != currentSourceIndex) switchSource(which, auto = false, reason = "") + } + .setOnDismissListener { showOsdTemporarily() } + .show() + } + + /** 在另一条线路里找当前这一集:优先同名,其次同序号 */ + private fun findEpisodeIn(source: PlaySource): Int? { + val cur = episodes.getOrNull(currentEpisodeIndex) + if (cur != null) { + val byName = source.episodes.indexOfFirst { it.name == cur.name } + if (byName >= 0) return byName + } + val idx = if (currentEpisodeIndex >= 0) currentEpisodeIndex else 0 + return idx.takeIf { it in source.episodes.indices } + } + + /** 换到第 [index] 条线路的同一集,并沿用当前播放进度 */ + private fun switchSource(index: Int, auto: Boolean, reason: String): Boolean { + val target = sources.getOrNull(index) ?: return false + val epIndex = findEpisodeIn(target) ?: run { + if (!auto) showFeedback("「${target.name}」没有这一集") + return false + } + if (!auto) saveCurrentProgress() + val pos = lastKnownPos + val fromName = currentSourceName() + cancelPreload() + hideEpisodePanel() + currentSourceIndex = index + episodes = target.episodes + currentEpisodeIndex = epIndex + retriedResolve = false + pendingStartPos = if (pos > 5000) pos else -1L + val ep = episodes[epIndex] + videoTitle = episodeTitle(ep) + binding.tvVideoTitle.text = videoTitle + updateEpisodeUi() + val status = if (auto) "线路「$fromName」$reason,自动切换到「${target.name}」…" else "正在切换到「${target.name}」…" + Log.d(TAG, "switch source -> #$index ${target.name} ep=${ep.name} auto=$auto") + resolveAndPlay(ep.url, needSiteInfo = false, status = status) + return true + } + + /** 当前线路解析 / 播放失败:自动换到下一条还没试过、且有这一集的线路 */ + private fun tryFailover(reason: String): Boolean { + if (sources.size <= 1 || currentSourceIndex < 0) return false + failedSources.add(currentSourceIndex) + for (step in 1 until sources.size) { + val idx = (currentSourceIndex + step) % sources.size + if (idx in failedSources) continue + if (findEpisodeIn(sources[idx]) == null) continue + if (switchSource(idx, auto = true, reason = reason)) return true + } + return false + } + + // --------------------------------------------------------------------------------------- + // 提前解析下一集 + // --------------------------------------------------------------------------------------- + + private fun maybePreloadNext(current: Long, duration: Long) { + if (!AppSettings.isPreloadNext(this)) return + if (!hasNextEpisode() || duration <= 0) return + val nextIndex = currentEpisodeIndex + 1 + if (preloadingIndex == nextIndex || preloaded?.index == nextIndex) return + val outro = AppSettings.getSkip(this, skipKey()).outroMs + val threshold = maxOf(PRELOAD_BEFORE_END_MS, outro + 60_000L) + if (duration - current > threshold) return + + val ep = episodes[nextIndex] + preloadingIndex = nextIndex + val r = preloadResolver ?: VideoResolver(this, binding.resolverHost, BrowserActivity.currentUserAgent(this)) + .also { preloadResolver = it } + Log.d(TAG, "preload next episode #$nextIndex ${ep.name}") + r.resolve(ep.url, false, object : VideoResolver.Callback { + override fun onResolved(source: VideoSource, siteInfo: SiteInfo?) { + if (preloadingIndex != nextIndex) return + preloadingIndex = -1 + preloaded = PreloadedEpisode(nextIndex, ep.url, source, System.currentTimeMillis()) + Log.d(TAG, "preloaded #$nextIndex: ${source.url}") + } + + override fun onFailed(reason: String) { + Log.w(TAG, "preload #$nextIndex failed: $reason") + // 失败不重试,切集时再正常解析;保持 preloadingIndex 防止反复触发 + } + }) + } + + private fun cancelPreload() { + preloadResolver?.cancel() + preloadingIndex = -1 + preloaded = null + } + + // --------------------------------------------------------------------------------------- + // 跳过片头片尾 + // --------------------------------------------------------------------------------------- + + private fun maybeSkipIntro() { + if (introChecked) return + introChecked = true + val intro = AppSettings.getSkip(this, skipKey()).introMs + val p = player ?: return + if (intro > 0 && p.currentPosition < intro - 1000) { + p.seekTo(intro) + showFeedback("已跳过片头(${formatTime(intro)})") + } + } + + private fun maybeSkipOutro(current: Long, duration: Long) { + if (outroTriggered || duration <= 0 || !hasNextEpisode()) return + val outro = AppSettings.getSkip(this, skipKey()).outroMs + if (outro <= 0 || outro >= duration) return + if (current >= duration - outro) { + outroTriggered = true + Log.d(TAG, "outro reached at ${formatTime(current)}, play next") + showFeedback("已跳过片尾") + onPlaybackEnded() + } + } + + private fun showSkipDialog() { + val p = player + val key = skipKey() + if (p == null || currentSource == null || key.isBlank()) { + Toast.makeText(this, "当前视频无法记忆片头片尾", Toast.LENGTH_SHORT).show() + return + } + mainHandler.removeCallbacks(hideOsdRunnable) + val cfg = AppSettings.getSkip(this, key) + val pos = p.currentPosition.coerceAtLeast(0L) + val duration = p.duration.takeIf { it != C.TIME_UNSET && it > 0 } ?: 0L + val title = buildString { + append("跳过片头片尾(本剧通用)\n") + append("片头:").append(if (cfg.introMs > 0) "${formatTime(cfg.introMs)} 前跳过" else "未设置") + append(" 片尾:").append(if (cfg.outroMs > 0) "最后 ${formatTime(cfg.outroMs)} 跳过" else "未设置") + } + val items = mutableListOf("设当前位置(${formatTime(pos)})为片头结束") + if (duration > 0) items.add("设当前位置为片尾开始(剩余 ${formatTime(duration - pos)})") + items.add("清除片头片尾设置") + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle(title) + .setItems(items.toTypedArray()) { _, which -> + val label = items[which] + when { + label.startsWith("设当前位置(") -> { + AppSettings.setSkip(this, key, cfg.copy(introMs = pos)) + showFeedback("已设置:片头到 ${formatTime(pos)}") + } + label.startsWith("设当前位置为片尾") -> { + val outro = (duration - pos).coerceAtLeast(0L) + AppSettings.setSkip(this, key, cfg.copy(outroMs = outro)) + // 本集就在片尾位置,不立刻跳走;从下一集开始生效 + outroTriggered = true + showFeedback("已设置:最后 ${formatTime(outro)} 为片尾") + } + else -> { + AppSettings.setSkip(this, key, AppSettings.SkipConfig(0, 0)) + showFeedback("已清除片头片尾设置") + } + } + } + .setOnDismissListener { showOsdTemporarily() } + .show() + } + + private fun showDecoderDialog() { + mainHandler.removeCallbacks(hideOsdRunnable) + val current = AppSettings.getDecoderMode(this) + val modes = arrayOf( + "自动(推荐 · 硬件优先,失败自动软解)", + "优先硬件解码(GPU加速 · 省电高效)", + "强制软件解码(CPU解码 · 解决花屏/黑绿屏)" + ) + val modeValues = intArrayOf( + AppSettings.DECODER_AUTO, + AppSettings.DECODER_HARDWARE, + AppSettings.DECODER_SOFTWARE + ) + val selectedIdx = modeValues.indexOf(current).coerceAtLeast(0) + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle("选择视频解码方式") + .setSingleChoiceItems(modes, selectedIdx) { dialog, which -> + dialog.dismiss() + val chosen = modeValues[which] + if (chosen != current) { + switchDecoder(chosen) + } + } + .setOnDismissListener { showOsdTemporarily() } + .show() + } + + private fun switchDecoder(newMode: Int) { + AppSettings.setDecoderMode(this, newMode) + val p = player + val curPos = p?.currentPosition?.takeIf { it > 0 } ?: lastKnownPos + val src = currentSource + p?.stop() + p?.release() + player = null + initPlayer() + if (src != null) { + pendingStartPos = curPos + playSource(src) + } + updateEpisodeUi() + showFeedback("已切换解码:${AppSettings.decoderModeName(newMode)}") + } + + private fun showStatsDialog() { + mainHandler.removeCallbacks(hideOsdRunnable) + val p = player + val vf = p?.videoFormat + val af = p?.audioFormat + val dur = p?.duration ?: 0L + val cur = p?.currentPosition ?: 0L + val bufferedPos = p?.bufferedPosition ?: 0L + val bufferedAheadSec = ((bufferedPos - cur).coerceAtLeast(0L)) / 1000L + val decoderMode = AppSettings.decoderModeName(AppSettings.getDecoderMode(this)) + + val info = buildString { + appendLine("🎬 视频标题:$videoTitle") + if (seriesTitle.isNotBlank()) appendLine("📺 剧集归属:$seriesTitle") + appendLine("⏱️ 播放进度:${formatTime(cur)} / ${if (dur > 0) formatTime(dur) else "--:--"}") + appendLine("🌐 实时网速:$currentSpeedStr") + appendLine("📦 已缓冲时长:${bufferedAheadSec} 秒 (${p?.bufferedPercentage ?: 0}%)") + val down = diagnostics.downloadBytesPerSec + val need = diagnostics.mediaBytesPerSec + appendLine("📥 片源下载速度:${if (down > 0) PlaybackDiagnostics.formatSpeed(down) else "统计中"}") + appendLine("🎯 视频所需速度:${if (need > 0) PlaybackDiagnostics.formatSpeed(need) else "统计中"}") + if (down > 0 && need > 0) { + appendLine(if (down < need * 11 / 10) " ⚠️ 片源速度跟不上视频码率,卡顿来自片源 / 线路,可换线路" else " ✅ 片源速度足够") + } + appendLine("🐢 本集卡顿次数:${diagnostics.rebufferCount}") + appendLine("❌ 分片下载失败:${diagnostics.loadErrorCount} 次" + (diagnostics.lastError?.let { "(最近:$it)" } ?: "")) + appendLine() + appendLine("⚙️ 解码方式:$decoderMode") + if (vf != null) { + val res = if (vf.width > 0 && vf.height > 0) "${vf.width} × ${vf.height}" else "未知" + val fps = if (vf.frameRate > 0) " @ ${vf.frameRate.toInt()} fps" else "" + appendLine("🎞️ 视频画面:$res$fps") + appendLine("🎞️ 视频编码:${vf.sampleMimeType ?: "未知"}") + if (!vf.codecs.isNullOrBlank()) appendLine("🎞️ 编码器代号:${vf.codecs}") + } else { + appendLine("🎞️ 视频画面:解析中 / 直播流") + } + if (af != null) { + appendLine("🔊 音频编码:${af.sampleMimeType ?: "未知"} (${af.channelCount} 声道 · ${af.sampleRate} Hz)") + } + appendLine("⚡ 播放倍速:${p?.playbackParameters?.speed ?: 1.0f}x") + appendLine("📐 画面比例:${ratioNames[currentRatioIndex]}") + currentSource?.let { src -> + appendLine() + appendLine("🔗 播放链接:") + appendLine(src.url.take(120) + if (src.url.length > 120) "…" else "") + } + } + + val tv = android.widget.TextView(this).apply { + text = info + textSize = 14f + setTextColor(android.graphics.Color.parseColor("#E2E8F0")) + setPadding(40, 24, 40, 24) + setLineSpacing(0f, 1.25f) + } + val scroll = android.widget.ScrollView(this).apply { + addView(tv) + } + + androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle("📊 播放详细参数") + .setView(scroll) + .setPositiveButton("确定", null) + .setOnDismissListener { showOsdTemporarily() } + .show() + } + + private fun updateEpisodeUi() { + binding.btnEpisodes.visibility = if (episodes.size > 1) View.VISIBLE else View.GONE + binding.btnNext.visibility = if (hasNextEpisode()) View.VISIBLE else View.GONE + if (currentSourceIndex < 0) syncSourceIndex() + binding.btnSource.visibility = if (sources.size > 1) View.VISIBLE else View.GONE + binding.btnSource.text = currentSourceName().let { if (it.isBlank()) "线路" else "线路:$it" } + binding.btnDecode.text = "解码: " + when (AppSettings.getDecoderMode(this)) { + AppSettings.DECODER_HARDWARE -> "硬解" + AppSettings.DECODER_SOFTWARE -> "软解" + else -> "自动" + } + binding.tvKeyHint.text = if (episodes.size > 1) { + "左右快进 · 确定暂停 · 上键选集 · 下键菜单" + } else { + "左右快进 · 确定暂停 · 下键菜单" + } + episodeAdapter.notifyDataSetChanged() + } + + private fun isEpisodePanelVisible() = binding.episodePanel.visibility == View.VISIBLE + + private fun showEpisodePanel() { + if (episodes.size <= 1) return + hideOsd() + val currentName = episodes.getOrNull(currentEpisodeIndex)?.name + binding.tvEpisodePanelTitle.text = buildString { + append(seriesTitle.ifBlank { "选集" }) + append(" · 共 ${episodes.size} 集") + if (currentName != null) append(" · 正在播放:$currentName") + } + // 面板最多显示 4 行,超出可滚动 + val rows = ceil(episodes.size / episodeSpan.toFloat()).toInt() + val rowHeight = (54 * resources.displayMetrics.density).toInt() + binding.episodeGrid.layoutParams = binding.episodeGrid.layoutParams.apply { + height = min(rows, 4) * rowHeight + } + binding.episodePanel.visibility = View.VISIBLE + updatePauseOverlay() + episodeAdapter.notifyDataSetChanged() + val target = currentEpisodeIndex.coerceAtLeast(0) + binding.episodeGrid.scrollToPosition(target) + binding.episodeGrid.post { + binding.episodeGrid.findViewHolderForAdapterPosition(target)?.itemView?.requestFocus() + ?: binding.episodeGrid.getChildAt(0)?.requestFocus() + } + } + + private fun hideEpisodePanel() { + binding.episodePanel.visibility = View.GONE + updatePauseOverlay() + } + + private inner class EpisodeAdapter : RecyclerView.Adapter() { + inner class VH(val b: ItemEpisodeBinding) : RecyclerView.ViewHolder(b.root) + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = + VH(ItemEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + + override fun getItemCount(): Int = episodes.size + + override fun onBindViewHolder(holder: VH, position: Int) { + val ep = episodes[position] + val isCurrent = position == currentEpisodeIndex + holder.b.episodeName.text = ep.name + val normalColor = if (isCurrent) 0xFF38BDF8.toInt() else 0xFFFFFFFF.toInt() + holder.b.episodeName.setTextColor( + android.content.res.ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_focused), intArrayOf()), + intArrayOf(0xFF0B1220.toInt(), normalColor) + ) + ) + holder.b.episodeName.paint.isFakeBoldText = isCurrent + holder.b.episodeName.setMarqueeEnabled(holder.b.episodeName.isFocused) + holder.b.episodeName.setOnFocusChangeListener { _, hasFocus -> + holder.b.episodeName.setMarqueeEnabled(hasFocus) + } + holder.b.episodeName.setOnClickListener { + if (position == currentEpisodeIndex && currentSource != null) hideEpisodePanel() + else playEpisode(position) + } + } + + override fun onViewRecycled(holder: VH) { + super.onViewRecycled(holder) + holder.b.episodeName.setMarqueeEnabled(false) + } + } + + // --------------------------------------------------------------------------------------- + // 控制 + // --------------------------------------------------------------------------------------- + + private fun togglePlayPause() { + val p = player ?: return + if (p.isPlaying) { + p.pause() + updatePauseOverlay() + showOsdTemporarily(focusPlay = true) + } else { + if (p.playbackState == Player.STATE_ENDED) p.seekTo(0) + p.play() + showFeedback("播放中 ▶") + updatePauseOverlay() + if (isOsdVisible()) { + mainHandler.removeCallbacks(hideOsdRunnable) + mainHandler.postDelayed(hideOsdRunnable, 5000) + } + } + updatePlayPauseButtonText() + } + + private fun updatePlayPauseButtonText() { + val playing = player?.playWhenReady == true && player?.playbackState != Player.STATE_ENDED + binding.btnPlayPause.text = if (playing) "暂停" else "播放" + } + + private fun updatePauseOverlay() { + val p = player + val isPaused = p != null && + p.playbackState == Player.STATE_READY && + !p.isPlaying && + !p.playWhenReady && + !isEpisodePanelVisible() && + binding.playerLoading.visibility != View.VISIBLE && + binding.tvFeedback.visibility != View.VISIBLE && + (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || !isInPictureInPictureMode) + binding.pauseOverlay.visibility = if (isPaused) View.VISIBLE else View.GONE + } + + private fun togglePlaybackSpeed() { + currentSpeedIndex = (currentSpeedIndex + 1) % speeds.size + val targetSpeed = speeds[currentSpeedIndex] + player?.setPlaybackSpeed(targetSpeed) + binding.btnSpeed.text = "${targetSpeed}x" + showFeedback("倍速:${targetSpeed}x") + showOsdTemporarily() + } + + private fun toggleAspectRatio() { + currentRatioIndex = (currentRatioIndex + 1) % ratioNames.size + binding.playerView.resizeMode = ratioModes[currentRatioIndex] + binding.btnRatio.text = ratioNames[currentRatioIndex] + showFeedback("画面:${ratioNames[currentRatioIndex]}") + showOsdTemporarily() + } + + /** 遥控快进 / 快退 */ + private fun seekRelative(offsetMs: Long) { + val p = player ?: return + val duration = p.duration + val max = if (duration != C.TIME_UNSET && duration > 0) duration else Long.MAX_VALUE + val target = (p.currentPosition + offsetMs).coerceIn(0L, max) + p.seekTo(target) + val sign = if (offsetMs > 0) "+" else "" + showFeedback("$sign${offsetMs / 1000}s (${formatTime(target)})") + updateProgress() + } + + /** 长按方向键加速:10s → 30s → 60s */ + private fun seekStep(event: KeyEvent): Long = when { + event.repeatCount >= 8 -> 60_000L + event.repeatCount >= 3 -> 30_000L + else -> 10_000L + } + + private fun showStatus(text: String) { + binding.tvStatus.text = text + binding.tvStatus.visibility = View.VISIBLE + } + + private fun showFeedback(text: String) { + binding.pauseOverlay.visibility = View.GONE + binding.tvFeedback.text = text + binding.tvFeedback.visibility = View.VISIBLE + binding.tvFeedback.removeCallbacks(hideFeedbackRunnable) + binding.tvFeedback.postDelayed(hideFeedbackRunnable, 1200) + } + + private val hideFeedbackRunnable = Runnable { + binding.tvFeedback.visibility = View.GONE + updatePauseOverlay() + } + + private fun isOsdVisible() = binding.playerOsd.visibility == View.VISIBLE + + private fun showOsdTemporarily(focusPlay: Boolean = false) { + binding.playerOsd.visibility = View.VISIBLE + if (focusPlay) binding.btnPlayPause.requestFocus() + mainHandler.removeCallbacks(hideOsdRunnable) + // 正在播放时 5 秒后自动隐藏;暂停时控制栏常驻显示,方便查看进度与操作 + if (player?.isPlaying == true) { + mainHandler.postDelayed(hideOsdRunnable, 5000) + } + } + + private fun hideOsd() { + mainHandler.removeCallbacks(hideOsdRunnable) + binding.playerOsd.visibility = View.GONE + hideSystemUI() + } + + private var lastSpeedCalcTime = 0L + private var lastTotalRxBytes = 0L + private var currentSpeedStr = "0 KB/s" + + private fun updateNetworkSpeed(): String { + val now = System.currentTimeMillis() + val uid = Process.myUid() + val rxBytes = TrafficStats.getUidRxBytes(uid) + if (rxBytes == TrafficStats.UNSUPPORTED.toLong()) { + return "0 KB/s" + } + if (lastSpeedCalcTime > 0 && now > lastSpeedCalcTime) { + val dtSec = (now - lastSpeedCalcTime) / 1000.0 + if (dtSec >= 0.8) { + val db = (rxBytes - lastTotalRxBytes).coerceAtLeast(0) + val bytesPerSec = (db / dtSec).toLong() + currentSpeedStr = when { + bytesPerSec >= 1024 * 1024 -> String.format(Locale.CHINA, "%.1f MB/s", bytesPerSec / (1024.0 * 1024.0)) + bytesPerSec >= 1024 -> "${bytesPerSec / 1024} KB/s" + else -> "$bytesPerSec B/s" + } + lastTotalRxBytes = rxBytes + lastSpeedCalcTime = now + } + } else { + lastTotalRxBytes = rxBytes + lastSpeedCalcTime = now + } + return currentSpeedStr + } + + private fun updateProgress() { + val p = player ?: return + val current = p.currentPosition.coerceAtLeast(0L) + val duration = p.duration + val bufferedPos = p.bufferedPosition.coerceAtLeast(0L) + val bufferedAheadSec = ((bufferedPos - current).coerceAtLeast(0L)) / 1000L + // 缓冲够了播放器会暂停下载,这时流量为 0 是正常的,不要显示成「网速 0」 + val rawSpeed = updateNetworkSpeed() + val speed = buildString { + append(if (!p.isLoading && bufferedAheadSec >= 10) "缓冲充足,暂停下载" else rawSpeed) + diagnostics.summary()?.let { append("(").append(it).append(")") } + } + // 播着播着卡住:给出原因(片源速度跟不上 / 分片下载失败),而不是只转圈 + if (p.playbackState == Player.STATE_BUFFERING && diagnostics.isStallAfterPlaying()) { + diagnostics.stallHint()?.let { + showStatus(it) + stallHintShown = true + } + } else if (stallHintShown && p.playbackState != Player.STATE_BUFFERING) { + stallHintShown = false + binding.tvStatus.visibility = View.GONE + } + + binding.tvCurrentTime.text = formatTime(current) + if (duration != C.TIME_UNSET && duration > 0) { + binding.tvTotalTime.text = formatTime(duration) + binding.playerSeekBar.progress = ((current * 1000) / duration).toInt() + val bufferPercent = p.bufferedPercentage + val remainingMs = (duration - current).coerceAtLeast(0L) + val remainingStr = formatTime(remainingMs) + val finishCal = Calendar.getInstance().apply { add(Calendar.MILLISECOND, remainingMs.toInt()) } + val finishTimeStr = String.format(Locale.CHINA, "%02d:%02d", finishCal.get(Calendar.HOUR_OF_DAY), finishCal.get(Calendar.MINUTE)) + binding.tvPlaybackInfo.text = "网速: $speed · 缓冲: ${bufferedAheadSec}秒 ($bufferPercent%) · 剩余: $remainingStr · 预计 $finishTimeStr 播完" + } else { + binding.tvTotalTime.text = "--:--" + binding.tvPlaybackInfo.text = "网速: $speed · 缓冲: ${bufferedAheadSec}秒 · 已播放: ${formatTime(current)}" + } + + if (p.playbackState == Player.STATE_READY && current > 0) lastKnownPos = current + + // 每隔 5 秒自动持久化保存一次播放断点进度 + val currentSec = current / 1000 + if (currentSec >= 5 && currentSec - lastSavedSec >= 5) { + lastSavedSec = currentSec + saveCurrentProgress() + } + + if (currentSource != null && p.playbackState == Player.STATE_READY && duration != C.TIME_UNSET && duration > 0) { + maybePreloadNext(current, duration) + maybeSkipOutro(current, duration) + } + } + + private fun saveCurrentProgress() { + val p = player ?: return + val key = historyKey() + val cur = p.currentPosition + if (suppressSave || cur <= 0 || key.isBlank() || currentSource == null) return + val dur = p.duration.takeIf { it != C.TIME_UNSET && it > 0 } ?: 0L + historyRepo.saveProgress( + key, videoTitle, cur.toInt(), dur.toInt(), + series = currentSeriesKey(), + seriesTitle = seriesTitle, + episodeName = episodes.getOrNull(currentEpisodeIndex)?.name.orEmpty() + ) + } + + /** 开启电视画中画 (PiP) 模式 */ + private fun enterPipMode() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + if (packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) { + hideOsd() + hideEpisodePanel() + binding.tvFeedback.visibility = View.GONE + val builder = PictureInPictureParams.Builder() + builder.setAspectRatio(Rational(16, 9)) + pipEnteredAt = android.os.SystemClock.uptimeMillis() + enterPictureInPictureMode(builder.build()) + } else { + Toast.makeText(this, "当前电视设备不支持画中画功能", Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + Log.e(TAG, "enterPictureInPictureMode failed", e) + Toast.makeText(this, "开启画中画失败", Toast.LENGTH_SHORT).show() + } + } else { + Toast.makeText(this, "系统版本需 Android 8.0+ 方可使用画中画", Toast.LENGTH_SHORT).show() + } + } + + /** + * 用户按 Home 键等主动离开时回调。 + * 不再自动进入画中画(画中画仅通过控制栏「画中画」按钮手动开启),直接暂停播放并保存进度。 + */ + override fun onUserLeaveHint() { + super.onUserLeaveHint() + val inPip = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && isInPictureInPictureMode + if (!inPip) pauseForBackground() + } + + /** 切到后台时暂停播放,并同步按钮文字 / 暂停浮层,回到前台时保持暂停状态 */ + private fun pauseForBackground() { + val p = player ?: return + if (p.playWhenReady) { + p.pause() + updatePlayPauseButtonText() + updatePauseOverlay() + } + saveCurrentProgress() + } + + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + if (isInPictureInPictureMode) { + pipInstanceRef = java.lang.ref.WeakReference(this) + pipEnteredAt = android.os.SystemClock.uptimeMillis() + hideOsd() + binding.tvFeedback.visibility = View.GONE + binding.pauseOverlay.visibility = View.GONE + } else { + if (pipInstanceRef?.get() === this) pipInstanceRef = null + // 退出画中画时界面已不可见(onStop 之后)= 用户关闭了小窗:直接结束播放器, + // 避免下次打开 App 时还残留一个暂停着的播放器 + if (!lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED)) { + saveCurrentProgress() + finish() + return + } + updatePauseOverlay() + if (lifecycle.currentState.isAtLeast(androidx.lifecycle.Lifecycle.State.RESUMED)) { + showOsdTemporarily() + } + } + } + + /** 小窗被系统拉回前台(恢复全屏)时会走这里:同一实例继续播放,无需重新加载 */ + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + if (intent.getBooleanExtra(EXTRA_RESTORE_FROM_PIP, false)) { + hideSystemUI() + return + } + } + + /** 构造「从画中画恢复全屏」的 Intent:携带当前片源、选集与进度,供系统未复用小窗时新建播放器兜底 */ + private fun buildRestoreIntent(): Intent { + val src = currentSource + return Intent(this, PlayerActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + putExtra(EXTRA_RESTORE_FROM_PIP, true) + if (src != null) { + putExtra(EXTRA_VIDEO_URL, src.url) + putExtra(EXTRA_HEADERS, src.headersBundle()) + putExtra(EXTRA_IS_HLS, src.isHls) + } + putExtra(EXTRA_VIDEO_TITLE, videoTitle) + currentPageUrl?.let { putExtra(EXTRA_PAGE_URL, it) } + if (seriesTitle.isNotBlank()) putExtra(EXTRA_SERIES_TITLE, seriesTitle) + if (sources.size > 1) putExtra(EXTRA_SOURCE_INDEX, currentSourceIndex) + if (episodes.isNotEmpty() || sources.isNotEmpty()) { + putExtra(EXTRA_SERIES_TOKEN, putSeriesPayload(SeriesPayload(episodes.toList(), sources.toList()))) + } + val pos = player?.currentPosition ?: 0L + if (pos > 0) putExtra(EXTRA_START_POSITION, pos) + } + } + + private fun formatTime(millis: Long): String { + if (millis <= 0) return "00:00" + val totalSec = millis / 1000 + val sec = totalSec % 60 + val min = (totalSec / 60) % 60 + val hour = totalSec / 3600 + return if (hour > 0) { + String.format(Locale.getDefault(), "%02d:%02d:%02d", hour, min, sec) + } else { + String.format(Locale.getDefault(), "%02d:%02d", min, sec) + } + } + + /** 调用系统或第三方专业播放器(Kodi / MX Player / VLC 等),附带请求头 */ + private fun launchExternalPlayer() { + val source = currentSource + if (source == null) { + Toast.makeText(this, "视频还在解析中,请稍候", Toast.LENGTH_SHORT).show() + return + } + try { + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(Uri.parse(source.url), if (source.isHls) "application/x-mpegURL" else "video/*") + putExtra("title", videoTitle) + // 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, "Failed to launch external player", e) + Toast.makeText(this, "未找到已安装的外部视频播放器", Toast.LENGTH_SHORT).show() + } + } + + // --------------------------------------------------------------------------------------- + // 遥控器按键 + // --------------------------------------------------------------------------------------- + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if (event.action == KeyEvent.ACTION_DOWN) { + // 媒体键:任何状态都生效 + when (event.keyCode) { + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> { togglePlayPause(); return true } + KeyEvent.KEYCODE_MEDIA_PLAY -> { + player?.play() + showFeedback("播放中 ▶") + return true + } + KeyEvent.KEYCODE_MEDIA_PAUSE -> { + player?.pause() + updatePauseOverlay() + showOsdTemporarily(focusPlay = true) + return true + } + KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { seekRelative(30_000); return true } + KeyEvent.KEYCODE_MEDIA_REWIND -> { seekRelative(-30_000); return true } + KeyEvent.KEYCODE_MEDIA_NEXT -> { playNextEpisode(); return true } + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> { playPrevEpisode(); return true } + } + + // 选集面板:交给系统焦点导航 + if (isEpisodePanelVisible()) return super.dispatchKeyEvent(event) + + // 控制栏显示中:正常焦点导航;进度条获得焦点时左右键直接快进快退 + if (isOsdVisible()) { + showOsdTemporarily() + val isLR = event.keyCode == KeyEvent.KEYCODE_DPAD_LEFT || event.keyCode == KeyEvent.KEYCODE_DPAD_RIGHT + if (isLR && currentFocus == binding.playerSeekBar) { + val step = seekStep(event) + seekRelative(if (event.keyCode == KeyEvent.KEYCODE_DPAD_LEFT) -step else step) + return true + } + if (event.keyCode == KeyEvent.KEYCODE_MENU) { hideOsd(); return true } + // 控制栏刚弹出、焦点还不在任何按钮上:方向键先把焦点放到播放按钮 + if (currentFocus == null || currentFocus == binding.playerRoot) { + when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> { + binding.btnPlayPause.requestFocus(); return true + } + } + } + return super.dispatchKeyEvent(event) + } + + // 无浮层:方向键直接控制播放 + when (event.keyCode) { + KeyEvent.KEYCODE_DPAD_LEFT -> { seekRelative(-seekStep(event)); return true } + KeyEvent.KEYCODE_DPAD_RIGHT -> { seekRelative(seekStep(event)); return true } + KeyEvent.KEYCODE_DPAD_UP -> { + if (episodes.size > 1) showEpisodePanel() else showOsdTemporarily(focusPlay = true) + return true + } + KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_MENU -> { + showOsdTemporarily(focusPlay = true); return true + } + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { + togglePlayPause(); return true + } + } + } + return super.dispatchKeyEvent(event) + } + + /** 触摸选集面板外部区域时直接收起选集面板,并消费本次手势避免误触底层控制栏 */ + private var dismissingEpisodePanelTouch = false + + 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 + return true + } + dismissingEpisodePanelTouch = false + } else if (dismissingEpisodePanelTouch) { + if (ev.actionMasked == android.view.MotionEvent.ACTION_UP || + ev.actionMasked == android.view.MotionEvent.ACTION_CANCEL + ) { + dismissingEpisodePanelTouch = false + } + return true + } + return super.dispatchTouchEvent(ev) + } + + private fun isTouchInsideView(rawX: Float, rawY: Float, view: View): Boolean { + if (view.visibility != View.VISIBLE) return false + val loc = IntArray(2) + view.getLocationOnScreen(loc) + val left = loc[0].toFloat() + val top = loc[1].toFloat() + return rawX >= left && rawX <= left + view.width && rawY >= top && rawY <= top + view.height + } + + @Deprecated("Deprecated in Java") + override fun onBackPressed() { + when { + isEpisodePanelVisible() -> hideEpisodePanel() + isOsdVisible() -> hideOsd() + else -> super.onBackPressed() + } + } + + override fun onResume() { + super.onResume() + hideSystemUI() + } + + override fun onPause() { + super.onPause() + val inPip = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) isInPictureInPictureMode else false + if (!inPip) pauseForBackground() else saveCurrentProgress() + } + + override fun onStop() { + super.onStop() + // 兜底:任何情况下界面完全不可见(含关闭画中画小窗)都暂停,避免后台继续出声 + pauseForBackground() + } + + override fun onDestroy() { + if (pipInstanceRef?.get() === this) pipInstanceRef = null + saveCurrentProgress() + mainHandler.removeCallbacksAndMessages(null) + resolver?.destroy() + resolver = null + preloadResolver?.destroy() + preloadResolver = null + binding.playerView.player = null + player?.release() + player = null + super.onDestroy() + } + + companion object { + private const val TAG = "PlayerActivity" + /** 选集格子目标宽度(dp),列数 = 屏幕宽度 / 该值,限制在 4~10 列 */ + private const val EPISODE_CELL_DP = 110 + /** 距结尾多久开始预解析下一集 */ + private const val PRELOAD_BEFORE_END_MS = 120_000L + /** 预解析结果有效期(签名地址可能过期) */ + private const val PRELOAD_VALID_MS = 10 * 60_000L + const val EXTRA_VIDEO_URL = "extra_video_url" + const val EXTRA_VIDEO_TITLE = "extra_video_title" + const val EXTRA_PAGE_URL = "extra_page_url" + const val EXTRA_HEADERS = "extra_headers" + const val EXTRA_IS_HLS = "extra_is_hls" + const val EXTRA_SERIES_TITLE = "extra_series_title" + const val EXTRA_SOURCE_INDEX = "extra_source_index" + /** 选集/线路数据的内存令牌(数据本身不走 Intent) */ + const val EXTRA_SERIES_TOKEN = "extra_series_token" + + /** + * 选集 + 线路列表可能非常大(如 195 集 × 多条线路 ≈ 500KB+), + * 放进 Intent 会超过 Binder 事务上限(约 1MB,且与其他事务共享), + * 导致 TransactionTooLargeException / 进程被杀(闪退)。 + * 因此只在 Intent 中传一个令牌,真实数据放在进程内存里。 + */ + private class SeriesPayload(val episodes: List, val sources: List) + + private val seriesPayloads = object : LinkedHashMap(4, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 4 + } + + private fun putSeriesPayload(payload: SeriesPayload): String = synchronized(seriesPayloads) { + val token = java.util.UUID.randomUUID().toString() + seriesPayloads[token] = payload + token + } + + private fun getSeriesPayload(token: String?): SeriesPayload? = + token?.let { synchronized(seriesPayloads) { seriesPayloads[it] } } + + /** 恢复画中画时携带:从小窗恢复全屏(新建实例兜底时也据此关闭旧小窗) */ + private const val EXTRA_RESTORE_FROM_PIP = "extra_restore_from_pip" + /** 恢复画中画时携带:从该位置继续播放(毫秒) */ + private const val EXTRA_START_POSITION = "extra_start_position" + /** 刚进入画中画后的保护时间:期间浏览器回到前台不触发恢复(避免一进小窗就被拉回全屏) */ + private const val PIP_RESTORE_GUARD_MS = 3000L + + /** 当前处于画中画小窗的播放器实例 */ + private var pipInstanceRef: java.lang.ref.WeakReference? = null + private var pipEnteredAt = 0L + + /** + * 从桌面重新打开 App 时调用:若播放器正以画中画小窗播放,则恢复为全屏播放器。 + * + * 背景:进入画中画时系统会把播放器移到单独的小窗任务里,桌面图标只会打开浏览器所在的任务, + * 所以小窗不会自动恢复。这里主动启动播放器: + * - 系统找到小窗任务并把它拉到前台 → 小窗直接放大为全屏(同一实例,播放不中断) + * - 个别系统没有复用小窗任务 → 用携带的当前片源 / 进度新建播放器,并关闭旧小窗 + * @return true 表示已发起恢复 + */ + fun restoreFromPipIfNeeded(context: Context): Boolean { + val inst = pipInstanceRef?.get() ?: return false + if (inst.isFinishing || inst.isDestroyed) { + pipInstanceRef = null + return false + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || !inst.isInPictureInPictureMode) return false + if (android.os.SystemClock.uptimeMillis() - pipEnteredAt < PIP_RESTORE_GUARD_MS) return false + return try { + context.startActivity(inst.buildRestoreIntent()) + true + } catch (e: Exception) { + Log.e(TAG, "restore from PiP failed", e) + false + } + } + + /** 加密流(央视网等)原生播放会花屏:拒绝启动并提示 */ + private fun rejectWebOnly(context: Context, url: String): Boolean { + if (!VideoSnifferHelper.isWebOnlyStream(url)) return false + Log.d(TAG, "reject web-only stream: $url") + android.widget.Toast.makeText(context, VideoSnifferHelper.WEB_ONLY_MESSAGE, android.widget.Toast.LENGTH_LONG).show() + return true + } + + /** 只有地址(本地文件 / 历史记录中的剧集页 / 直链) */ + fun start(context: Context, url: String, title: String = "") { + if (rejectWebOnly(context, url)) return + val intent = Intent(context, PlayerActivity::class.java).apply { + putExtra(EXTRA_VIDEO_URL, url) + putExtra(EXTRA_VIDEO_TITLE, title) + } + context.startActivity(intent) + } + + /** 浏览器嗅探到的视频:带来源页、请求头与选集信息 */ + fun start(context: Context, source: VideoSource, siteInfo: SiteInfo?) { + if (rejectWebOnly(context, source.url)) return + val intent = Intent(context, PlayerActivity::class.java).apply { + putExtra(EXTRA_VIDEO_URL, source.url) + putExtra(EXTRA_VIDEO_TITLE, siteInfo?.displayTitle()?.ifBlank { null } ?: source.title) + putExtra(EXTRA_PAGE_URL, source.pageUrl) + putExtra(EXTRA_HEADERS, source.headersBundle()) + putExtra(EXTRA_IS_HLS, source.isHls) + if (siteInfo != null) { + val episodes = siteInfo.episodes + val sources = if (siteInfo.sources.size > 1) siteInfo.sources else emptyList() + if (episodes.isNotEmpty()) putExtra(EXTRA_SERIES_TITLE, siteInfo.title) + if (sources.isNotEmpty()) putExtra(EXTRA_SOURCE_INDEX, siteInfo.sourceIndex) + if (episodes.isNotEmpty() || sources.isNotEmpty()) { + putExtra(EXTRA_SERIES_TOKEN, putSeriesPayload(SeriesPayload(episodes.toList(), sources.toList()))) + } + } + } + context.startActivity(intent) + } + } +} diff --git a/app/src/main/java/com/tv/browser/PlayerSsl.kt b/app/src/main/java/com/tv/browser/PlayerSsl.kt new file mode 100644 index 0000000..5fb566c --- /dev/null +++ b/app/src/main/java/com/tv/browser/PlayerSsl.kt @@ -0,0 +1,80 @@ +package com.tv.browser + +import android.annotation.SuppressLint +import android.util.Log +import java.security.cert.CertPathValidatorException +import java.security.cert.CertificateException +import java.security.cert.X509Certificate +import javax.net.ssl.HttpsURLConnection +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLException +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +/** + * 原生播放器的 SSL 兼容处理。 + * + * 网页里 WebView 遇到证书错误会继续加载(见 onReceivedSslError),但 ExoPlayer 走的是系统 HttpURLConnection, + * 证书过期 / 旧电视缺少新根证书(如 Let's Encrypt ISRG Root X1)/ 电视时间不准时会直接报 + * ERROR_CODE_IO_NETWORK_CONNECTION_FAILED。这里在检测到 SSL 类错误后开启"宽松模式",与网页行为保持一致。 + * + * 注意:HttpsURLConnection 的默认工厂是进程级的,开启后本进程内的 HttpURLConnection 请求都不再校验证书 + * (WebView 不受影响)。只在播放器确实遇到 SSL 错误时才开启。 + */ +object PlayerSsl { + private const val TAG = "PlayerSsl" + + @Volatile + var lenient: Boolean = false + private set + + /** 错误链中是否包含证书 / SSL 握手问题 */ + fun isSslError(error: Throwable?): Boolean { + var e = error + var depth = 0 + while (e != null && depth < 10) { + if (e is SSLException || e is CertificateException || e is CertPathValidatorException) return true + val name = e.javaClass.name + if (name.contains("SSL", ignoreCase = true) || name.contains("Cert", ignoreCase = true)) return true + e = e.cause + depth++ + } + return false + } + + /** 取错误链最底层的原因,用于界面提示与日志 */ + fun rootCauseText(error: Throwable?): String { + var e = error ?: return "" + var depth = 0 + while (e.cause != null && e.cause !== e && depth < 10) { + e = e.cause!! + depth++ + } + val msg = e.message?.take(80).orEmpty() + return if (msg.isBlank()) e.javaClass.simpleName else "${e.javaClass.simpleName}: $msg" + } + + /** 开启宽松模式:信任所有证书、不校验主机名。返回 true 表示本次新开启 */ + @SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager", "BadHostnameVerifier") + @Synchronized + fun enableLenient(): Boolean { + if (lenient) return false + return try { + val trustAll = object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = emptyArray() + } + val ctx = SSLContext.getInstance("TLS") + ctx.init(null, arrayOf(trustAll), java.security.SecureRandom()) + HttpsURLConnection.setDefaultSSLSocketFactory(ctx.socketFactory) + HttpsURLConnection.setDefaultHostnameVerifier { _, _ -> true } + lenient = true + Log.w(TAG, "lenient SSL enabled for native player") + true + } catch (e: Exception) { + Log.e(TAG, "enableLenient failed", e) + false + } + } +} diff --git a/app/src/main/java/com/tv/browser/PlayerTuning.kt b/app/src/main/java/com/tv/browser/PlayerTuning.kt new file mode 100644 index 0000000..0baed74 --- /dev/null +++ b/app/src/main/java/com/tv/browser/PlayerTuning.kt @@ -0,0 +1,217 @@ +package com.tv.browser + +import android.os.SystemClock +import androidx.annotation.OptIn +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.HttpDataSource +import androidx.media3.exoplayer.DefaultLoadControl +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.source.LoadEventInfo +import androidx.media3.exoplayer.source.MediaLoadData +import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy +import java.io.IOException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.util.Locale + +/** + * 原生播放器的缓冲 / 重试参数。 + * + * 默认参数是为手机准备的:缓冲上限 50 秒、读超时由调用方决定。影视站片源常见的情况是开头速度快、 + * 之后被限速或偶尔卡住一个连接,这里: + * - 网速好时多缓冲一些(最多 120 秒),扛住片源中途的降速;同时按应用可用内存限制缓冲字节数,避免低内存电视频繁 GC + * - 网络类错误多重试几次、间隔更短;签名过期类(401/403/410)不空等,尽快交给上层重新解析拿新地址 + */ +@OptIn(UnstableApi::class) +object PlayerTuning { + + const val CONNECT_TIMEOUT_MS = 8_000 + /** 单个连接卡住多久算失败(默认 20 秒太久,期间缓冲会被耗光) */ + const val READ_TIMEOUT_MS = 10_000 + + fun buildLoadControl(): DefaultLoadControl { + val heap = Runtime.getRuntime().maxMemory() + val targetBytes = (heap / 4).coerceIn(16L * 1024 * 1024, 96L * 1024 * 1024).toInt() + return DefaultLoadControl.Builder() + .setBufferDurationsMs( + /* minBufferMs = */ 50_000, + /* maxBufferMs = */ 120_000, + /* bufferForPlaybackMs = */ 2_500, + /* bufferForPlaybackAfterRebufferMs = */ 4_000 + ) + .setTargetBufferBytes(targetBytes) + .setPrioritizeTimeOverSizeThresholds(false) + .build() + } + + class ErrorPolicy : DefaultLoadErrorHandlingPolicy(6) { + override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long { + val code = responseCodeOf(loadErrorInfo.exception) + if (code == 401 || code == 403 || code == 410) { + return if (loadErrorInfo.errorCount <= 1) 1_000L else C.TIME_UNSET + } + val base = super.getRetryDelayMsFor(loadErrorInfo) + if (base == C.TIME_UNSET) return base + return minOf(loadErrorInfo.errorCount * 500L, 2_000L) + } + } + + fun responseCodeOf(error: Throwable?): Int? { + var e = error + var depth = 0 + while (e != null && depth < 8) { + if (e is HttpDataSource.InvalidResponseCodeException) return e.responseCode + e = e.cause + depth++ + } + return null + } +} + +/** + * 播放诊断:按分片统计「片源实际下载速度」与「视频本身需要的码率」,记录卡顿与分片失败, + * 用来判断卡顿是片源 / 线路跟不上,还是分片地址失效。 + * + * 系统流量统计(TrafficStats)在缓冲满、播放器暂停下载时会显示 0,容易误以为网速卡; + * 这里只统计真正在下载的分片:字节数 ÷ 下载耗时 = 片源能给的速度;字节数 ÷ 分片时长 = 播放需要的速度。 + */ +@OptIn(UnstableApi::class) +class PlaybackDiagnostics : AnalyticsListener { + + /** 片源分片下载速度(字节/秒,平滑后) */ + @Volatile var downloadBytesPerSec: Long = 0L + private set + /** 视频需要的速度(字节/秒,平滑后) */ + @Volatile var mediaBytesPerSec: Long = 0L + private set + @Volatile var rebufferCount: Int = 0 + private set + @Volatile var loadErrorCount: Int = 0 + private set + @Volatile var lastError: String? = null + private set + @Volatile private var lastErrorAt: Long = 0L + + private var everReady = false + private var ignoreNextBuffering = false + + /** 换片源时清零 */ + fun reset() { + downloadBytesPerSec = 0L + mediaBytesPerSec = 0L + rebufferCount = 0 + loadErrorCount = 0 + lastError = null + lastErrorAt = 0L + everReady = false + ignoreNextBuffering = false + } + + override fun onLoadCompleted(eventTime: AnalyticsListener.EventTime, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) { + if (mediaLoadData.dataType != C.DATA_TYPE_MEDIA) return + // 独立音轨分片码率很低,混进来会拉低「需要的速度」,只统计视频 / 音视频混合分片 + if (mediaLoadData.trackType == C.TRACK_TYPE_AUDIO) return + val bytes = loadEventInfo.bytesLoaded + val ms = loadEventInfo.loadDurationMs + if (bytes < 32 * 1024 || ms <= 0) return + downloadBytesPerSec = smooth(downloadBytesPerSec, bytes * 1000 / ms) + val start = mediaLoadData.mediaStartTimeMs + val end = mediaLoadData.mediaEndTimeMs + if (start != C.TIME_UNSET && end != C.TIME_UNSET && end - start >= 500) { + mediaBytesPerSec = smooth(mediaBytesPerSec, bytes * 1000 / (end - start)) + } + } + + override fun onLoadError( + eventTime: AnalyticsListener.EventTime, + loadEventInfo: LoadEventInfo, + mediaLoadData: MediaLoadData, + error: IOException, + wasCanceled: Boolean + ) { + if (wasCanceled) return + loadErrorCount++ + lastError = describe(error) + lastErrorAt = SystemClock.elapsedRealtime() + } + + override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) { + when (state) { + Player.STATE_READY -> { + everReady = true + ignoreNextBuffering = false + } + Player.STATE_BUFFERING -> { + if (everReady && !ignoreNextBuffering) rebufferCount++ + ignoreNextBuffering = false + } + } + } + + override fun onPositionDiscontinuity( + eventTime: AnalyticsListener.EventTime, + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + // 用户拖动进度后的缓冲不算卡顿 + if (reason == Player.DISCONTINUITY_REASON_SEEK) ignoreNextBuffering = true + } + + /** 已开始播放后又进入缓冲(即「播着播着卡了」) */ + fun isStallAfterPlaying(): Boolean = everReady + + /** 卡顿时给用户的原因提示;判断不出来时返回 null */ + fun stallHint(): String? { + val err = lastError + if (err != null && SystemClock.elapsedRealtime() - lastErrorAt < 15_000) { + return "片源分片下载失败($err),正在重试…" + } + val down = downloadBytesPerSec + val need = mediaBytesPerSec + if (down > 0 && need > 0 && down < need * 11 / 10) { + return "片源下载 ${formatSpeed(down)},低于本视频需要的 ${formatSpeed(need)}\n片源 / 线路跟不上,可换「线路」试试" + } + if (down > 0 && need > 0) { + return "缓冲中… 片源下载 ${formatSpeed(down)},视频需要 ${formatSpeed(need)}" + } + return null + } + + /** OSD 一行简述:片源速度 / 需要速度 */ + fun summary(): String? { + val down = downloadBytesPerSec + val need = mediaBytesPerSec + if (down <= 0) return null + return if (need > 0) "片源 ${formatSpeed(down)} / 需 ${formatSpeed(need)}" else "片源 ${formatSpeed(down)}" + } + + private fun smooth(old: Long, sample: Long): Long = + if (old <= 0) sample else (old * 0.6 + sample * 0.4).toLong() + + private fun describe(error: Throwable): String { + PlayerTuning.responseCodeOf(error)?.let { return "HTTP $it" } + var e: Throwable? = error + var depth = 0 + while (e != null && depth < 8) { + when (e) { + is SocketTimeoutException -> return "连接超时" + is UnknownHostException -> return "域名解析失败" + } + e = e.cause + depth++ + } + return error.javaClass.simpleName + } + + companion object { + fun formatSpeed(bytesPerSec: Long): String = when { + bytesPerSec >= 1024 * 1024 -> String.format(Locale.CHINA, "%.1f MB/s", bytesPerSec / (1024.0 * 1024.0)) + bytesPerSec >= 1024 -> "${bytesPerSec / 1024} KB/s" + else -> "$bytesPerSec B/s" + } + } +} diff --git a/app/src/main/java/com/tv/browser/PlaylistCache.kt b/app/src/main/java/com/tv/browser/PlaylistCache.kt new file mode 100644 index 0000000..d8cabd0 --- /dev/null +++ b/app/src/main/java/com/tv/browser/PlaylistCache.kt @@ -0,0 +1,128 @@ +package com.tv.browser + +import android.net.Uri +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.ByteArrayDataSource +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener + +/** + * 网页里已经下载过的 m3u8 清单内容缓存。 + * + * 很多影视站的 m3u8 地址是「一次性」的(带时间戳/签名,请求一次后即失效,再请求会返回 + * "链接失效" 之类的页面)。网页里的 hls.js 先请求过之后,原生播放器再请求就拿不到了。 + * 因此由页面 Hook 把 hls.js 拿到的清单原文回传过来,原生播放器直接用这份内容,不再重复请求。 + */ +object PlaylistCache { + + data class Entry(val text: String, val finalUrl: String) { + /** 主清单(多码率列表) */ + val isMaster: Boolean get() = text.contains("#EXT-X-STREAM-INF") + /** 点播清单(有结束标记,内容不会变化) */ + val isVod: Boolean get() = text.contains("#EXT-X-ENDLIST") + } + + private const val MAX_ENTRIES = 30 + private const val MAX_TEXT_LENGTH = 4 * 1024 * 1024 + + private val map = object : LinkedHashMap(32, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = + size > MAX_ENTRIES + } + + /** 记录清单内容;url 为请求地址,finalUrl 为重定向后的最终地址(用于解析相对路径) */ + @Synchronized + fun put(url: String, finalUrl: String?, text: String) { + if (!text.startsWith("#EXTM3U") || text.length > MAX_TEXT_LENGTH) return + val entry = Entry(text, finalUrl?.takeIf { it.startsWith("http") } ?: url) + map[url] = entry + if (entry.finalUrl != url) map[entry.finalUrl] = entry + } + + @Synchronized + fun contains(url: String): Boolean = map.containsKey(url) + + /** 清单是否已可直接播放:子清单已缓存;主清单则至少有一个子清单也已缓存 */ + @Synchronized + fun isReady(url: String): Boolean { + val entry = map[url] ?: return false + if (!entry.isMaster) return true + val base = Uri.parse(entry.finalUrl) + return entry.text.lineSequence() + .map { it.trim() } + .filter { it.isNotEmpty() && !it.startsWith("#") } + .any { line -> map.containsKey(resolve(base, line)) } + } + + private fun resolve(base: Uri, ref: String): String = + try { + java.net.URI(base.toString()).resolve(ref).toString() + } catch (_: Exception) { + ref + } + + @Synchronized + fun get(url: String): Entry? = map[url] + + /** + * 播放器取用清单:直播清单(无结束标记的子清单)只用一次,之后的刷新走网络,避免一直播放旧内容。 + */ + @Synchronized + fun take(url: String): Entry? { + val entry = map[url] ?: return null + if (!entry.isMaster && !entry.isVod) map.remove(url) + return entry + } +} + +/** + * 原生播放器的数据源包装:命中 [PlaylistCache] 的清单直接从内存读取,其余(分片、密钥等)走网络。 + */ +@OptIn(UnstableApi::class) +class PlaylistCacheDataSource(private val upstream: DataSource) : DataSource { + + private var cached: ByteArrayDataSource? = null + private var cachedUri: Uri? = null + + override fun addTransferListener(transferListener: TransferListener) { + upstream.addTransferListener(transferListener) + } + + override fun open(dataSpec: DataSpec): Long { + val entry = PlaylistCache.take(dataSpec.uri.toString()) + if (entry != null) { + val ds = ByteArrayDataSource(entry.text.toByteArray(Charsets.UTF_8)) + cached = ds + cachedUri = Uri.parse(entry.finalUrl) + return ds.open(dataSpec) + } + cached = null + cachedUri = null + return upstream.open(dataSpec) + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int = + cached?.read(buffer, offset, length) ?: upstream.read(buffer, offset, length) + + override fun getUri(): Uri? = if (cached != null) cachedUri else upstream.uri + + override fun getResponseHeaders(): Map> = + if (cached != null) emptyMap() else upstream.responseHeaders + + override fun close() { + val c = cached + if (c != null) { + c.close() + cached = null + } else { + upstream.close() + } + } + + class Factory(private val upstreamFactory: DataSource.Factory) : DataSource.Factory { + override fun createDataSource(): DataSource = + PlaylistCacheDataSource(upstreamFactory.createDataSource()) + } +} diff --git a/app/src/main/java/com/tv/browser/PurifyHelper.kt b/app/src/main/java/com/tv/browser/PurifyHelper.kt new file mode 100644 index 0000000..dc7d6a5 --- /dev/null +++ b/app/src/main/java/com/tv/browser/PurifyHelper.kt @@ -0,0 +1,453 @@ +package com.tv.browser + +/** + * 净化播放页(类似夸克的影视页净化): + * 1. 识别:站点适配脚本已拿到选集,且当前地址就在选集列表里(排除详情页),页面上找得到播放器 + * 2. 保留:播放器节点原地不动(移动 iframe 会导致重载),只把它固定到左上方,并隐藏它到 body 路径上的所有兄弟节点 + * 3. 重绘:右侧按播放源分标签页的选集按钮(默认打开正在播放的线路),播放器下方片名与「上集 / 全屏 / 加书签 / 下集 / 原网页」 + * + * 全部通过 CSS 类实现,可随时还原(「原网页」按钮或关闭开关)。 + * 继续使用网站自己的播放器,因此央视网等只能网页播放的加密流同样适用。 + */ +object PurifyHelper { + + /** 原生层回调接口名 */ + const val BRIDGE_NAME = "AndroidPurify" + + /** 生成注入脚本:定义 __tvPurify 并用站点适配结果(JSON)执行 */ + fun buildApplyScript(siteInfoJson: String): String = + "$JS_PURIFY\n;try{window.__tvPurify($siteInfoJson);}catch(e){}" + + /** 还原原网页(并在本页内不再自动净化) */ + const val JS_RESTORE = "try{window.__tvPurifyRestore&&window.__tvPurifyRestore(true);}catch(e){}" + + /** 网页内全屏(没有可原生播放的视频源时,由原生层回调) */ + const val JS_WEB_FULLSCREEN = "try{window.__tvPurifyWebFullscreen&&window.__tvPurifyWebFullscreen();}catch(e){}" + + private val JS_PURIFY = """ + (function() { + if (window.__tvPurify) return; + var STYLE_ID = '__tv_purify_css', ROOT_ID = '__tv_purify_root'; + var state = null; + + function norm(u) { + try { + var a = new URL(u, location.href); + return (a.host + a.pathname.replace(/\/+$/, '') + a.search).toLowerCase(); + } catch (e) { return ''; } + } + function rectOf(el) { + try { return el.getBoundingClientRect(); } catch (e) { return { width: 0, height: 0 }; } + } + + // ---------------- 找播放器 ---------------- + var KNOWN = ['.MacPlayer', '#MacPlayer', '#playleft', '#cms_play', '#zanpiancms_player', '.player-box .player-left', + '#player', '.dplayer', '#dplayer', '.art-video-player', '#mse', '.video-player', '.player_video', '#video']; + function isKnown(n) { + var m = n.matches || n.webkitMatchesSelector; + if (!m) return false; + for (var k = 0; k < KNOWN.length; k++) { try { if (m.call(n, KNOWN[k])) return true; } catch (e) {} } + return false; + } + /** 节点内含选集链接 → 是播放器 + 选集的大容器,不能整体当播放器(选集会被一起放进播放器框里,挤掉视频) */ + function hasEpLink(node, epSet) { + if (!epSet) return false; + var as = node.getElementsByTagName('a'); + for (var i = 0; i < as.length && i < 500; i++) { + if (as[i].getAttribute('href') && epSet[norm(as[i].href)]) return true; + } + return false; + } + function findPlayer(epSet) { + var best = null, bestArea = 0; + var els = document.querySelectorAll('video, iframe, embed, object'); + for (var i = 0; i < els.length; i++) { + var el = els[i]; + if (el.closest && el.closest('#' + ROOT_ID)) continue; + var r = rectOf(el); + if (r.width < 240 || r.height < 135 || r.width < r.height) continue; + if (r.width * r.height > bestArea) { best = el; bestArea = r.width * r.height; } + } + if (!best) { + for (var k = 0; k < KNOWN.length; k++) { + var c = document.querySelector(KNOWN[k]); + if (!c) continue; + var cr = rectOf(c); + if (cr.width >= 240 && cr.height >= 135) return c; + } + return null; + } + // 向上找尺寸基本一致的外层(播放器控件条 / 16:9 占位容器),保留完整播放器; + // 遇到常见 CMS 播放器容器即停,含选集链接的外层不要 + var n = best, br = rectOf(best), depth = 0; + while ((n === best || !isKnown(n)) && n.parentElement && n.parentElement !== document.body && depth < 6) { + var pr = rectOf(n.parentElement); + if (pr.width <= br.width * 1.15 + 4 && pr.height <= br.height * 1.35 + 4 && !hasEpLink(n.parentElement, epSet)) { n = n.parentElement; depth++; } + else break; + } + // 常见 CMS 播放器容器包含了它(控件条可能在视频外侧)且尺寸相近时,以容器为准,避免控件被裁掉 + if (best.closest) { + for (var q = 0; q < KNOWN.length; q++) { + var kc = best.closest(KNOWN[q]); + if (!kc || kc === n || kc === document.body || !kc.contains(n) || hasEpLink(kc, epSet)) continue; + var kr = rectOf(kc); + if (kr.width <= br.width * 1.3 + 8 && kr.height <= br.height * 1.6 + 60) { n = kc; break; } + } + } + return n; + } + + /** 播放器内部真正的视频 / iframe(播放器本身就是时返回自己) */ + function mediaOf(p) { + var t = p.tagName; + if (t === 'VIDEO' || t === 'IFRAME' || t === 'EMBED' || t === 'OBJECT') return p; + var els = p.querySelectorAll('video, iframe, embed, object'), best = null, bestArea = 0; + for (var i = 0; i < els.length; i++) { + var r = rectOf(els[i]); + if (r.width * r.height > bestArea) { best = els[i]; bestArea = r.width * r.height; } + } + return best; + } + + // ---------------- 样式 ---------------- + // 注意兼容电视上的旧版系统 WebView(Chromium 5x~7x): + // 不用 CSS min()/max()/clamp()(79+,不支持时整条声明失效)、不用 flex gap(84+)、不用 inset / aspect-ratio。 + // 播放器与片名按钮条的精确像素位置由 layout() 按实际视口计算,下面的 vw/vh 只是兜底。 + var CSS = [ + // 整页用文档自身滚动(不要做内部滚动区):遥控器光标边缘滚动 / 手机遥控都是滚动 WebView 本身 + 'html.tvp{background:#0f1115 !important;overflow-x:hidden !important;overflow-y:auto !important;height:auto !important;}', + 'html.tvp body{background:#0f1115 !important;margin:0 !important;padding:0 !important;overflow:visible !important;height:auto !important;min-height:0 !important;}', + '.tvp-hide{display:none !important;}', + '.tvp-anc{transform:none !important;-webkit-transform:none !important;filter:none !important;-webkit-filter:none !important;backdrop-filter:none !important;', + ' contain:none !important;perspective:none !important;-webkit-perspective:none !important;will-change:auto !important;animation:none !important;', + ' clip-path:none !important;-webkit-clip-path:none !important;-webkit-mask:none !important;mask:none !important;}', + // 播放器的外层容器塌缩为 0 高度(播放器本身已 fixed),避免在选集上方留下空白 + '.tvp-anc:not(.tvp-player){height:0 !important;min-height:0 !important;padding:0 !important;margin:0 !important;border:0 !important;}', + // 不加圆角:旧版 WebView 对 video / iframe 合成层做圆角裁剪时,部分电视 GPU 会画不全 + '.tvp-player{position:fixed !important;left:2vw !important;top:3vh !important;right:auto !important;bottom:auto !important;', + ' width:62vw !important;height:34.875vw !important;min-width:0 !important;min-height:0 !important;max-width:none !important;max-height:none !important;', + ' margin:0 !important;padding:0 !important;border:0 !important;box-sizing:border-box !important;float:none !important;', + ' z-index:2147483000 !important;background:#000 !important;border-radius:0 !important;overflow:hidden !important;}', + '.tvp-player iframe,.tvp-player video,iframe.tvp-player,video.tvp-player{width:100% !important;height:100% !important;', + ' min-width:0 !important;min-height:0 !important;max-width:none !important;max-height:none !important;}', + '.tvp-player iframe,.tvp-player video{left:0 !important;top:0 !important;margin:0 !important;}', + '#R{position:relative;display:block;z-index:2147482000;color:#e5e7eb;padding:3vh 2vw 3vh 66vw;min-height:100vh;box-sizing:border-box;', + ' font:16px/1.45 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;letter-spacing:normal;text-align:left;}', + '#R *{box-sizing:border-box;float:none;text-transform:none;letter-spacing:normal;}', + // 测量 fixed 定位实际参照的视口尺寸(与缩放 / 怪异模式无关) + '#R .tvp-probe{position:fixed;left:0;top:0;width:100%;height:100%;visibility:hidden;pointer-events:none;z-index:-1;}', + // 片名与按钮条固定在播放器下方;选集列在右侧随页面滚动 + '#R .tvp-info{position:fixed;z-index:2147483001;left:2vw;width:62vw;top:calc(5vh + 34.875vw);}', + '#R .tvp-eps{display:block;background:#171a21;border-radius:10px;padding:14px 16px;}', + '#R h1.tvp-title{margin:0 0 12px;padding:0;font-size:22px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}', + '#R .tvp-bar{display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;}', + '#R .tvp-btn{display:-webkit-inline-box;display:-webkit-inline-flex;display:inline-flex;-webkit-align-items:center;align-items:center;', + ' -webkit-justify-content:center;justify-content:center;min-width:88px;height:40px;padding:0 16px;margin:0 10px 10px 0;border:0;', + ' border-radius:8px;background:#232733;color:#e5e7eb;font-size:16px;text-decoration:none;cursor:pointer;outline:none;}', + '#R .tvp-btn.disabled{opacity:.35;pointer-events:none;}', + '#R .tvp-btn:hover,#R .tvp-btn:focus,#R .tvp-ep:hover,#R .tvp-ep:focus{background:#2563eb;color:#fff;}', + '#R .tvp-src{margin:2px 0 8px;font-size:15px;color:#94a3b8;}', + '#R .tvp-src.cur{color:#38bdf8;font-weight:700;}', + // 播放源标签页:选集很长时标签条吸顶(旧版不支持 sticky 时只是不吸顶) + '#R .tvp-tabs{display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;position:-webkit-sticky;position:sticky;top:0;z-index:1;', + ' margin:-14px -16px 12px;padding:12px 16px 4px;background:#171a21;border-bottom:1px solid #2a2f3a;border-radius:10px 10px 0 0;}', + '#R .tvp-tab{display:inline-block;max-width:100%;padding:7px 14px;margin:0 8px 8px 0;border-radius:8px;background:#232733;color:#94a3b8;', + ' font-size:15px;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;outline:none;cursor:pointer;border:2px solid transparent;}', + '#R .tvp-tab.playing{color:#38bdf8;}', + '#R .tvp-tab.on{background:#1e3a8a;color:#fff;font-weight:700;border-color:#3b82f6;}', + '#R .tvp-tab:hover,#R .tvp-tab:focus{background:#2563eb;color:#fff;}', + '#R .tvp-cnt{display:inline-block;margin-left:6px;padding:0 6px;border-radius:9px;background:rgba(255,255,255,.12);font-size:12px;font-weight:400;line-height:18px;vertical-align:1px;}', + '#R .tvp-off{display:none !important;}', + '#R .tvp-list{display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;margin-bottom:8px;}', + '#R .tvp-ep{display:inline-block;min-width:72px;max-width:100%;padding:8px 12px;margin:0 8px 8px 0;border-radius:8px;background:#232733;color:#cbd5e1;', + ' font-size:15px;text-align:center;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;outline:none;}', + '#R .tvp-ep.cur{background:#1e3a8a;color:#93c5fd;font-weight:700;}', + '@media (max-aspect-ratio:1/1){', + ' .tvp-player{left:0 !important;top:0 !important;width:100vw !important;height:56.25vw !important;border-radius:0 !important;}', + ' #R{padding:calc(56.25vw + 12px) 0 0 0;}', + ' #R .tvp-info{position:static;padding:0 3vw 12px;width:auto;}', + ' #R .tvp-eps{border-radius:0;}', + '}' + ].join('\n').replace(/#R/g, '#' + ROOT_ID); + + function el(tag, cls, text) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + } + function linkBtn(text, url) { + var a = el('a', 'tvp-btn' + (url ? '' : ' disabled'), text); + a.href = url || 'javascript:void(0)'; + a.setAttribute('target', '_self'); + return a; + } + function actionBtn(text, fn) { + var a = el('a', 'tvp-btn', text); + a.href = 'javascript:void(0)'; + a.addEventListener('click', function(ev) { ev.preventDefault(); ev.stopPropagation(); fn(); }, true); + return a; + } + + // ---------------- 隐藏无关内容 ---------------- + function shouldKeep(n) { + if (!n || n.nodeType !== 1) return true; + if (n.id === ROOT_ID || n.id === STYLE_ID) return true; + var t = n.tagName; + return t === 'SCRIPT' || t === 'STYLE' || t === 'LINK' || t === 'META' || t === 'HEAD' || t === 'TITLE' || t === 'NOSCRIPT'; + } + function hideSiblings(node) { + var p = node.parentElement; + if (!p) return; + for (var c = p.firstElementChild; c; c = c.nextElementSibling) { + if (c !== node && !shouldKeep(c) && !c.classList.contains('tvp-anc')) c.classList.add('tvp-hide'); + } + } + + function buildPanel(info, player) { + var cur = norm(location.href); + var groups = (info.sources && info.sources.length > 1) + ? info.sources + : [{ name: '选集', episodes: info.episodes || [] }]; + // 当前所在线路与集数 + var gi = -1, ei = -1; + for (var g = 0; g < groups.length && gi < 0; g++) { + var eps = groups[g].episodes || []; + for (var e = 0; e < eps.length; e++) { if (norm(eps[e].url) === cur) { gi = g; ei = e; break; } } + } + if (gi < 0) return null; // 当前地址不在选集里:不是播放页(如详情页) + var list = groups[gi].episodes; + var prev = ei > 0 ? list[ei - 1].url : ''; + var next = info.next || (ei + 1 < list.length ? list[ei + 1].url : ''); + + var root = el('div'); + root.id = ROOT_ID; + root.appendChild(el('div', 'tvp-probe')); + var infoBox = el('div', 'tvp-info'); + var title = (info.title || document.title || '').trim(); + var epName = (list[ei] && list[ei].name) || info.episodeName || ''; + infoBox.appendChild(el('h1', 'tvp-title', title + (epName && title.indexOf(epName) < 0 ? ' ' + epName : ''))); + var bar = el('div', 'tvp-bar'); + bar.appendChild(linkBtn('⏮ 上集', prev)); + bar.appendChild(actionBtn('⛶ 全屏', function() { + var b = window.${BRIDGE_NAME}; + if (b && b.onFullscreen) b.onFullscreen(); else window.__tvPurifyWebFullscreen(); + })); + bar.appendChild(actionBtn('☆ 加书签', function() { + var b = window.${BRIDGE_NAME}; + if (b && b.onBookmark) b.onBookmark(); + })); + bar.appendChild(linkBtn('下集 ⏭', next)); + bar.appendChild(actionBtn('↩ 原网页', function() { window.__tvPurifyRestore(true); })); + infoBox.appendChild(bar); + root.appendChild(infoBox); + + var epsBox = el('div', 'tvp-eps'); + // 按播放源分标签页:默认打开正在播放的线路,点标签切换(只是切换显示,不跳转) + var tabs = [], panes = []; + function showTab(idx) { + for (var t = 0; t < tabs.length; t++) { + var on = tabs[t].idx === idx; + tabs[t].btn.classList[on ? 'add' : 'remove']('on'); + panes[t].classList[on ? 'remove' : 'add']('tvp-off'); + } + } + var tabBar = el('div', 'tvp-tabs'); + for (var gg = 0; gg < groups.length; gg++) { + var grp = groups[gg]; + if (!grp.episodes || !grp.episodes.length) continue; + var name = grp.name || ('线路' + (gg + 1)); + var tab = actionBtn((gg === gi ? '▶ ' : '') + name, (function(idx) { return function() { showTab(idx); }; })(gg)); + tab.className = 'tvp-tab' + (gg === gi ? ' playing' : ''); + tab.appendChild(el('span', 'tvp-cnt', String(grp.episodes.length))); + tab.title = name + '(' + grp.episodes.length + ' 集)'; + tabBar.appendChild(tab); + var box = el('div', 'tvp-list'); + for (var k = 0; k < grp.episodes.length; k++) { + var ep = grp.episodes[k]; + var a = el('a', 'tvp-ep' + (gg === gi && k === ei ? ' cur' : ''), ep.name || ('第' + (k + 1) + '集')); + a.href = ep.url; + a.title = ep.name || ''; + a.setAttribute('target', '_self'); + box.appendChild(a); + } + tabs.push({ idx: gg, btn: tab }); + panes.push(box); + } + if (tabs.length > 1) epsBox.appendChild(tabBar); + else epsBox.appendChild(el('div', 'tvp-src cur', (tabs.length && groups.length > 1 ? (groups[gi].name || '选集') : '选集') + ' · 播放中')); + for (var pi = 0; pi < panes.length; pi++) epsBox.appendChild(panes[pi]); + showTab(gi); + root.appendChild(epsBox); + return root; + } + + /** + * 按实际视口计算播放器与片名按钮条的像素位置(替代 CSS min(),旧版 WebView 不支持)。 + * 视口尺寸取自 fixed 定位的探针元素,与页面缩放 / 怪异模式无关;工具栏显隐导致 WebView 变高变矮时由 resize 重新计算。 + */ + function layout() { + if (!state) return; + var vr = rectOf(state.probe); + var W = Math.round(vr.width), H = Math.round(vr.height); + if (!W || !H) return; + var P = 'html.tvp .tvp-player', R = 'html.tvp #' + ROOT_ID, css; + if (W <= H) { + var ph0 = Math.round(W * 9 / 16); + css = P + '{left:0 !important;top:0 !important;width:' + W + 'px !important;height:' + ph0 + 'px !important;}\n' + + R + '{padding:' + (ph0 + 12) + 'px 0 0 0;}\n' + + R + ' .tvp-info{position:static;width:auto;padding:0 3vw 12px;}'; + } else { + var side = Math.round(W * 0.02), top = Math.round(H * 0.03), gap = Math.max(8, Math.round(H * 0.02)); + var pw = Math.round(W * 0.62); + var infoH = (state.info && state.info.offsetHeight) || 90; + // 16:9,但播放器下方至少给片名与按钮条留出位置(WebView 较矮时缩小高度,网页播放器自身会留黑边) + var ph = Math.min(Math.round(pw * 9 / 16), H - top * 2 - gap - infoH); + ph = Math.max(ph, Math.round(H * 0.35)); + css = P + '{left:' + side + 'px !important;top:' + top + 'px !important;width:' + pw + 'px !important;height:' + ph + 'px !important;}\n' + + R + '{padding:' + top + 'px ' + side + 'px ' + top + 'px ' + (side * 2 + pw) + 'px;}\n' + + R + ' .tvp-info{position:fixed;left:' + side + 'px;width:' + pw + 'px;top:' + (top + ph + gap) + 'px;}'; + } + if (state.dyn.textContent !== css + state.fillCss) state.dyn.textContent = css + state.fillCss; + } + + function pct(x) { + x = Math.round(x * 10000) / 100; + return (x > 98.5 && x < 101.5) ? 100 : Math.max(0, Math.min(100, x)); + } + /** + * 播放器内部从视频 / iframe 到播放器之间的每层包裹,按改样式前的原始比例换算成百分比宽高。 + * 网站常按原尺寸写死 px 宽高 / max-width / padding-top 比例占位,播放器框变小后这些层不跟着缩, + * 视频与控件条会被裁掉一部分;换成百分比后整个播放器按比例缩放。 + */ + function measureFills(player, media) { + var rules = []; + if (!media || media === player) return ''; + for (var f = media.parentElement, idx = 0; f && f !== player; f = f.parentElement, idx++) { + var pos = ''; + try { pos = window.getComputedStyle(f).position; } catch (e) {} + var ref = (pos === 'absolute' || pos === 'fixed') ? f.offsetParent : f.parentElement; + if (!ref || (ref !== player && !player.contains(ref))) break; + var fr = rectOf(f), rr = rectOf(ref); + if (!fr.width || !fr.height || !rr.width || !rr.height) break; + f.setAttribute('data-tvp-fill', String(idx)); + // 同层还有外置控件条 / 标题条(一般几十 px):这些条保持原高度,视频区占剩余部分;否则按比例 + var dh = Math.round(rr.height - fr.height); + var h = (pos !== 'absolute' && dh > 0 && dh <= 120) ? 'calc(100% - ' + dh + 'px)' : pct(fr.height / rr.height) + '%'; + var r = '.tvp-player [data-tvp-fill="' + idx + '"]{width:' + pct(fr.width / rr.width) + '% !important;height:' + h + ' !important;' + + 'min-width:0 !important;min-height:0 !important;max-width:none !important;max-height:none !important;' + + 'margin:0 !important;padding:0 !important;box-sizing:border-box !important;float:none !important;'; + if (pos === 'absolute') { + r += 'left:' + pct((fr.left - rr.left) / rr.width) + '% !important;top:' + pct((fr.top - rr.top) / rr.height) + '% !important;right:auto !important;bottom:auto !important;'; + } else if (pos === 'relative' || pos === 'sticky') { + r += 'left:0 !important;top:0 !important;'; + } + rules.push(r + '}'); + } + return rules.length ? '\n' + rules.join('\n') : ''; + } + + function episodeSet(info) { + var set = {}, groups = (info.sources && info.sources.length) ? info.sources : [{ episodes: info.episodes || [] }]; + for (var g = 0; g < groups.length; g++) { + var eps = groups[g].episodes || []; + for (var e = 0; e < eps.length; e++) { var k = norm(eps[e].url); if (k) set[k] = true; } + } + return set; + } + + function apply(info) { + if (state || window.__tvPurifyDisabled) return true; + var player = findPlayer(episodeSet(info)); + if (!player) return false; + var root = buildPanel(info, player); + if (!root) return true; // 不是播放页,不再重试 + + // 改样式前按原尺寸算好内部包裹层的比例 + var fillCss = measureFills(player, mediaOf(player)); + + var style = document.getElementById(STYLE_ID); + if (!style) { + style = el('style'); + style.id = STYLE_ID; + style.textContent = CSS; + (document.head || document.documentElement).appendChild(style); + } + var dyn = el('style'); + dyn.id = STYLE_ID + '_dyn'; + (document.head || document.documentElement).appendChild(dyn); + document.documentElement.classList.add('tvp'); + player.classList.add('tvp-player'); + var chain = []; + for (var n = player; n && n !== document.body && n !== document.documentElement; n = n.parentElement) { + n.classList.add('tvp-anc'); + chain.push(n); + } + for (var i = 0; i < chain.length; i++) hideSiblings(chain[i]); + document.body.appendChild(root); + + // 之后插入的广告 / 弹窗节点(挂在 body 或播放器的祖先下)也一并隐藏 + var obs = null; + try { + obs = new MutationObserver(function(list) { + for (var m = 0; m < list.length; m++) { + var added = list[m].addedNodes; + for (var j = 0; j < added.length; j++) { + var a = added[j]; + if (a.nodeType === 1 && !shouldKeep(a) && !a.classList.contains('tvp-anc')) a.classList.add('tvp-hide'); + } + } + }); + obs.observe(document.body, { childList: true }); + for (var c = 0; c < chain.length; c++) { + if (chain[c].parentElement && chain[c].parentElement !== document.body) obs.observe(chain[c].parentElement, { childList: true }); + } + } catch (e) {} + state = { player: player, root: root, style: style, dyn: dyn, obs: obs, fillCss: fillCss, + probe: root.querySelector('.tvp-probe'), info: root.querySelector('.tvp-info'), onResize: null }; + layout(); + state.onResize = function() { layout(); }; + try { window.addEventListener('resize', state.onResize); } catch (e) {} + // 字体 / 网页播放器尺寸稍后才稳定,补算几次 + setTimeout(layout, 300); + setTimeout(layout, 1200); + try { window.scrollTo(0, 0); } catch (e) {} + return true; + } + + window.__tvPurify = function(info) { + if (!info || window.__tvPurifyDisabled) return; + if (info.current && norm(info.current) !== norm(location.href)) return; + var tries = 0; + (function attempt() { + if (apply(info)) return; + if (++tries < 12) setTimeout(attempt, 500); // 播放器 iframe 常由脚本稍后插入 + })(); + }; + + window.__tvPurifyRestore = function(disableForPage) { + if (disableForPage) window.__tvPurifyDisabled = true; + if (!state) return; + try { if (state.obs) state.obs.disconnect(); } catch (e) {} + try { if (state.onResize) window.removeEventListener('resize', state.onResize); } catch (e) {} + var hidden = document.querySelectorAll('.tvp-hide,.tvp-anc,.tvp-player'); + for (var i = 0; i < hidden.length; i++) hidden[i].classList.remove('tvp-hide', 'tvp-anc', 'tvp-player'); + var fills = document.querySelectorAll('[data-tvp-fill]'); + for (var fi = 0; fi < fills.length; fi++) fills[fi].removeAttribute('data-tvp-fill'); + document.documentElement.classList.remove('tvp'); + if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root); + if (state.style && state.style.parentNode) state.style.parentNode.removeChild(state.style); + if (state.dyn && state.dyn.parentNode) state.dyn.parentNode.removeChild(state.dyn); + state = null; + }; + + window.__tvPurifyWebFullscreen = function() { + var p = state && state.player; + if (!p) return; + var t = (p.tagName === 'IFRAME' || p.tagName === 'VIDEO') ? p : (p.querySelector('video') || p.querySelector('iframe') || p); + var fn = t.requestFullscreen || t.webkitRequestFullscreen; + if (fn) { try { fn.call(t); } catch (e) {} } + }; + })(); + """.trimIndent() +} diff --git a/app/src/main/java/com/tv/browser/RemoteControlCallback.kt b/app/src/main/java/com/tv/browser/RemoteControlCallback.kt new file mode 100644 index 0000000..070b781 --- /dev/null +++ b/app/src/main/java/com/tv/browser/RemoteControlCallback.kt @@ -0,0 +1,21 @@ +package com.tv.browser + +/** + * 手机遥控回调:电视端收到网页指令后执行光标移动、点击、按键等。 + */ +interface RemoteControlCallback { + /** 相对移动光标(像素) */ + fun onRemoteMove(dx: Int, dy: Int) + /** 在光标位置点击(确认) */ + fun onRemoteClick() + /** 双指滑动滚动页面 */ + fun onRemoteScroll(dx: Int, dy: Int) + /** 遥控按键:back, home, menu, volume_up, volume_down, mute, refresh, fullscreen, dpad_* */ + fun onRemoteKey(keyCode: String) + /** 文本输入:从遥控网页发送到当前焦点输入框 */ + fun onRemoteText(text: String) + /** 推送网址:在电视端立即打开并加载该网址 */ + fun onRemoteOpenUrl(url: String) + /** 局域网快传:手机端向电视上传文件(APK 或多媒体文件) */ + fun onRemoteFileUploaded(file: java.io.File, filename: String) +} diff --git a/app/src/main/java/com/tv/browser/RemoteControlDialog.kt b/app/src/main/java/com/tv/browser/RemoteControlDialog.kt new file mode 100644 index 0000000..0d580d5 --- /dev/null +++ b/app/src/main/java/com/tv/browser/RemoteControlDialog.kt @@ -0,0 +1,227 @@ +package com.tv.browser + +import android.graphics.Bitmap +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.text.Editable +import android.text.TextWatcher +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.EditText +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.fragment.app.DialogFragment +import com.google.zxing.BarcodeFormat +import com.google.zxing.qrcode.QRCodeWriter +import java.net.Inet4Address +import java.net.NetworkInterface +import java.util.regex.Pattern + +/** + * 手机遥控弹窗:显示二维码与连接地址。服务由 Activity 常驻,关弹窗不关服务。 + * 每 5 秒检测服务状态并更新标识;模拟器调试时使用 adb reverse tcp:8767 tcp:8766。 + */ +class RemoteControlDialog : DialogFragment() { + + /** 模拟器调试时,手机访问电脑的端口(adb reverse 左侧端口) */ + private val emulatorHostPort = 8767 + + private var qrBitmap: Bitmap? = null + private val mainHandler = Handler(Looper.getMainLooper()) + private var statusCheckRunnable: Runnable? = null + private val statusCheckIntervalMs = 5000L + + private val browserActivity: BrowserActivity? + get() = activity as? BrowserActivity + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val root = inflater.inflate(R.layout.dialog_remote_control, container, false) + val statusText = root.findViewById(R.id.remoteStatus) + val statusDot = root.findViewById(R.id.remoteServerStatusDot) + val statusTextRow = root.findViewById(R.id.remoteServerStatusText) + val btnStartServer = root.findViewById