This commit is contained in:
haomingming
2026-11-09 08:47:15 +08:00
commit a7224d19f0
99 changed files with 13980 additions and 0 deletions
@@ -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()
}
@@ -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()}"
}
}
@@ -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
}
}
}
@@ -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<Bookmark>,
private val onOpen: (Bookmark) -> Unit,
private val onRemove: (Bookmark) -> Unit
) : BaseAdapter() {
fun updateList(newList: List<Bookmark>) {
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
}
}
@@ -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<Bookmark> {
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<Bookmark> {
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<Bookmark>): List<Bookmark> {
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<Bookmark>) {
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 = "星辰影院"),
)
}
}
@@ -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
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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()
}
@@ -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<ContinueWatchingAdapter.VH>() {
private var items: List<PlaybackRecord> = emptyList()
class VH(val b: ItemContinueBinding) : RecyclerView.ViewHolder(b.root)
fun submit(list: List<PlaybackRecord>) {
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)
}
}
@@ -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<HistoryItem>,
private val onOpen: (HistoryItem) -> Unit
) : RecyclerView.Adapter<HistoryAdapter.ViewHolder>() {
fun updateList(newList: List<HistoryItem>) {
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)
}
@@ -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
}
}
}
@@ -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
}
@@ -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<HistoryItem> {
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<HistoryItem>) {
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
}
}
@@ -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<Bookmark>,
private val onItemClick: (Bookmark) -> Unit,
private val onDelete: (Bookmark) -> Unit
) : RecyclerView.Adapter<HomeCardAdapter.ViewHolder>() {
/** 是否处于卡片整理(编辑)模式: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<Bookmark>) {
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
}
}
@@ -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
}
}
@@ -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<javax.net.ssl.TrustManager>(
object : javax.net.ssl.X509TrustManager {
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate>? = null
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, 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
}
}
@@ -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<PlaybackRecord> {
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<PlaybackRecord> =
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<PlaybackRecord>) {
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
}
}
File diff suppressed because it is too large Load Diff
@@ -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<out X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val ctx = SSLContext.getInstance("TLS")
ctx.init(null, arrayOf<TrustManager>(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
}
}
}
@@ -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"
}
}
}
@@ -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<String, Entry>(32, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Entry>?): 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<String, List<String>> =
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())
}
}
@@ -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()
}
@@ -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)
}
@@ -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<TextView>(R.id.remoteStatus)
val statusDot = root.findViewById<View>(R.id.remoteServerStatusDot)
val statusTextRow = root.findViewById<TextView>(R.id.remoteServerStatusText)
val btnStartServer = root.findViewById<Button>(R.id.btnStartRemoteServer)
val emulatorSection = root.findViewById<LinearLayout>(R.id.remoteEmulatorSection)
val hostIpInput = root.findViewById<EditText>(R.id.remoteHostIpInput)
val qrImage = root.findViewById<ImageView>(R.id.remoteQr)
val urlText = root.findViewById<TextView>(R.id.remoteUrl)
val btnOpenOnTv = root.findViewById<Button>(R.id.btnOpenOnTv)
val btnRestartServer = root.findViewById<Button>(R.id.btnRestartRemoteServer)
val btnClose = root.findViewById<Button>(R.id.btnCloseRemote)
val onEmulator = isEmulator()
val port = browserActivity?.getRemoteControlPort() ?: 8766
// 模拟器下允许软键盘输入电脑 IP;真实 TV 下若未连接也可点击弹出输入法
if (!onEmulator) {
try {
hostIpInput.showSoftInputOnFocus = false
} catch (_: Exception) { }
}
fun updateServerStatusUi(running: Boolean) {
if (running) {
statusDot.setBackgroundResource(R.drawable.remote_status_dot_running)
statusTextRow.text = getString(R.string.remote_server_status_running)
btnStartServer.visibility = View.GONE
} else {
statusDot.setBackgroundResource(R.drawable.remote_status_dot_stopped)
statusTextRow.text = getString(R.string.remote_server_status_stopped)
btnStartServer.visibility = View.VISIBLE
}
}
fun scheduleStatusCheck() {
statusCheckRunnable?.let { mainHandler.removeCallbacks(it) }
statusCheckRunnable = Runnable {
if (!isAdded) return@Runnable
val running = browserActivity?.isRemoteServerRunning() == true
mainHandler.post {
if (isAdded) updateServerStatusUi(running)
scheduleStatusCheck()
}
}
mainHandler.postDelayed(statusCheckRunnable!!, statusCheckIntervalMs)
}
updateServerStatusUi(browserActivity?.isRemoteServerRunning() == true)
scheduleStatusCheck()
btnStartServer.setOnClickListener {
browserActivity?.ensureRemoteControlServerStarted()
updateServerStatusUi(browserActivity?.isRemoteServerRunning() == true)
}
btnRestartServer.setOnClickListener {
browserActivity?.restartRemoteControlServer()
updateServerStatusUi(browserActivity?.isRemoteServerRunning() == true)
}
fun updateQrAndUrl(url: String?) {
if (url != null) {
urlText.text = url
qrBitmap?.recycle()
qrBitmap = generateQrBitmap(url, 512)
qrImage.setImageBitmap(qrBitmap)
qrImage.visibility = View.VISIBLE
} else {
urlText.text = ""
qrImage.visibility = View.GONE
}
}
if (onEmulator) {
emulatorSection.visibility = View.VISIBLE
statusText.text = getString(R.string.remote_control_connecting) + "\n(当前为模拟器,请按下方说明操作)"
updateQrAndUrl(null)
hostIpInput.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
val ip = s?.toString()?.trim()?.takeIf { isValidIp(it) }
updateQrAndUrl(if (ip != null) "http://$ip:$emulatorHostPort/" else null)
}
})
} else {
emulatorSection.visibility = View.GONE
val ip = NetworkChecker.getLocalIpAddress()
val url = if (ip != null) "http://$ip:$port/" else null
if (url != null) {
statusText.text = getString(R.string.remote_control_connecting)
updateQrAndUrl(url)
} else {
statusText.text = getString(R.string.remote_control_connecting) + "\n请确保电视已连接 WiFi"
updateQrAndUrl(null)
}
}
btnOpenOnTv.setOnClickListener {
browserActivity?.loadUrlInBrowser("http://127.0.0.1:$port/")
}
btnClose.setOnClickListener { dismiss() }
return root
}
override fun onStart() {
super.onStart()
(activity as? BrowserActivity)?.setCursorOverlayVisible(false, this)
}
override fun onDestroyView() {
(activity as? BrowserActivity)?.setCursorOverlayVisible(true, this)
statusCheckRunnable?.let { mainHandler.removeCallbacks(it) }
statusCheckRunnable = null
qrBitmap?.recycle()
qrBitmap = null
super.onDestroyView()
}
private fun isEmulator(): Boolean {
return (Build.FINGERPRINT.startsWith("generic")
|| Build.FINGERPRINT.startsWith("unknown")
|| Build.MODEL.contains("sdk")
|| Build.MODEL.contains("Emulator")
|| Build.MODEL.contains("Android SDK")
|| Build.MANUFACTURER.contains("Genymotion")
|| (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
|| "google_sdk" == Build.PRODUCT
|| Build.HARDWARE.contains("goldfish")
|| Build.HARDWARE.contains("ranchu")
|| Build.HARDWARE.contains("vbox")
|| Build.HARDWARE.contains("vexpress")
|| Build.HARDWARE.contains("sdk"))
}
private fun isValidIp(s: String): Boolean {
if (s.isEmpty()) return false
val ipPattern = Pattern.compile(
"^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
)
return ipPattern.matcher(s).matches()
}
private fun getLocalIpAddress(): String? {
try {
val interfaces = NetworkInterface.getNetworkInterfaces()?.toList() ?: return null
val sorted = interfaces.sortedByDescending { ni ->
when {
ni.name.startsWith("wlan", ignoreCase = true) -> 2
ni.name.startsWith("eth", ignoreCase = true) -> 1
else -> 0
}
}
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
}
private fun generateQrBitmap(content: String, sizePx: Int): Bitmap {
val writer = QRCodeWriter()
val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, sizePx, sizePx)
val w = bitMatrix.width
val h = bitMatrix.height
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.RGB_565)
for (x in 0 until w) {
for (y in 0 until h) {
bitmap.setPixel(x, y, if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE)
}
}
return bitmap
}
}
@@ -0,0 +1,220 @@
package com.tv.browser
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import fi.iki.elonen.NanoHTTPD
import org.json.JSONObject
import java.io.ByteArrayInputStream
import java.io.File
import java.io.IOException
import kotlin.jvm.Volatile
private const val TAG = "RemoteControlServer"
private const val PORT = 8766
/**
* 本地 HTTP 服务:提供遥控网页,接收 POST /cmd 的 JSON 指令并回调到主线程。
*/
class RemoteControlServer(
private val context: Context,
private val mainHandler: Handler = Handler(Looper.getMainLooper()),
private val callback: RemoteControlCallback?
) : NanoHTTPD(PORT) {
@Volatile
private var running = false
/** 服务是否仍在运行(用于状态检测;与 NanoHTTPD.isAlive 区分,避免 override final) */
fun isServerRunning(): Boolean = running
override fun start() {
super.start()
running = true
}
override fun stop() {
try {
super.stop()
} finally {
running = false
}
}
private val htmlPage: String by lazy {
try {
context.assets.open("remote.html").bufferedReader().use { it.readText() }
} catch (e: Exception) {
Log.e(TAG, "Failed to load remote.html", e)
"<html><body>Failed to load remote page.</body></html>"
}
}
val port: Int get() = PORT
private fun newCorsResponse(
status: NanoHTTPD.Response.IStatus,
mimeType: String,
data: ByteArray
): NanoHTTPD.Response {
return newFixedLengthResponse(status, mimeType, ByteArrayInputStream(data), data.size.toLong()).apply {
addHeader("Access-Control-Allow-Origin", "*")
addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
addHeader("Access-Control-Max-Age", "86400")
}
}
override fun serve(session: IHTTPSession): NanoHTTPD.Response {
val uri = session.uri
if (session.method == NanoHTTPD.Method.OPTIONS) {
return newCorsResponse(
NanoHTTPD.Response.Status.OK,
"text/plain",
ByteArray(0)
)
}
if (session.method == NanoHTTPD.Method.GET && (uri == "/" || uri == "/remote" || uri.isEmpty())) {
val htmlBytes = htmlPage.toByteArray(Charsets.UTF_8)
return newCorsResponse(
NanoHTTPD.Response.Status.OK,
"text/html; charset=utf-8",
htmlBytes
)
}
if (session.method == NanoHTTPD.Method.POST && uri == "/cmd") {
return handleCommand(session)
}
if (session.method == NanoHTTPD.Method.POST && uri == "/upload") {
return handleUpload(session)
}
return newCorsResponse(
NanoHTTPD.Response.Status.NOT_FOUND,
"text/plain",
"Not Found".toByteArray(Charsets.UTF_8)
)
}
private fun handleCommand(session: IHTTPSession): NanoHTTPD.Response {
var body: String? = null
try {
val files = HashMap<String, String>()
session.parseBody(files)
body = files["postData"]
} catch (e: Exception) {
Log.e(TAG, "parseBody failed", e)
}
if (body.isNullOrBlank()) {
try {
val contentLength = session.headers["content-length"]?.toIntOrNull() ?: 0
if (contentLength > 0 && contentLength < 4096) {
val buf = ByteArray(contentLength)
var offset = 0
val stream = session.inputStream
while (offset < contentLength) {
val n = stream.read(buf, offset, contentLength - offset)
if (n <= 0) break
offset += n
}
if (offset > 0) {
body = String(buf, 0, offset, Charsets.UTF_8)
}
}
} catch (e: IOException) {
Log.e(TAG, "Read body fallback failed", e)
}
}
if (!body.isNullOrBlank()) {
try {
val json = JSONObject(body)
val type = json.optString("type", "")
mainHandler.post {
when (type) {
"move" -> callback?.onRemoteMove(
json.optInt("dx", 0),
json.optInt("dy", 0)
)
"click" -> callback?.onRemoteClick()
"scroll" -> callback?.onRemoteScroll(
json.optInt("dx", 0),
json.optInt("dy", 0)
)
"key" -> callback?.onRemoteKey(json.optString("keyCode", ""))
"text" -> callback?.onRemoteText(json.optString("text", ""))
"open_url" -> callback?.onRemoteOpenUrl(json.optString("url", ""))
else -> { }
}
}
} catch (e: Exception) {
Log.e(TAG, "Parse cmd failed: $body", e)
}
}
return newCorsResponse(
NanoHTTPD.Response.Status.OK,
"application/json",
"{}".toByteArray(Charsets.UTF_8)
)
}
private fun handleUpload(session: IHTTPSession): NanoHTTPD.Response {
try {
val files = HashMap<String, String>()
session.parseBody(files)
var rawFilename = session.parms["filename"] ?: ""
if (rawFilename.isBlank()) {
rawFilename = session.parameters["filename"]?.firstOrNull() ?: ""
}
if (rawFilename.isBlank()) {
rawFilename = "upload_${System.currentTimeMillis()}"
}
val cleanFilename = File(rawFilename).name.ifBlank { "uploaded_file" }
val tempFilePath = files["file"] ?: files.values.firstOrNull()
if (tempFilePath != null) {
val tempFile = File(tempFilePath)
if (tempFile.exists() && tempFile.length() > 0) {
val uploadDir = File(context.getExternalFilesDir("uploads") ?: context.filesDir, "uploads")
uploadDir.mkdirs()
val targetFile = File(uploadDir, cleanFilename)
tempFile.copyTo(targetFile, overwrite = true)
mainHandler.post {
callback?.onRemoteFileUploaded(targetFile, cleanFilename)
}
val respJson = JSONObject().apply {
put("status", "ok")
put("filename", cleanFilename)
put("size", targetFile.length())
}
return newCorsResponse(
NanoHTTPD.Response.Status.OK,
"application/json",
respJson.toString().toByteArray(Charsets.UTF_8)
)
}
}
} catch (e: Exception) {
Log.e(TAG, "File upload handling failed", e)
val errJson = JSONObject().apply {
put("status", "error")
put("message", e.message ?: "unknown")
}
return newCorsResponse(
NanoHTTPD.Response.Status.INTERNAL_ERROR,
"application/json",
errJson.toString().toByteArray(Charsets.UTF_8)
)
}
return newCorsResponse(
NanoHTTPD.Response.Status.BAD_REQUEST,
"application/json",
"{\"status\":\"error\",\"message\":\"No file received\"}".toByteArray(Charsets.UTF_8)
)
}
}
@@ -0,0 +1,31 @@
package com.tv.browser
import android.content.Context
import android.util.AttributeSet
import android.webkit.WebView
/**
* TV 场景专用 WebView:
* - 不作为文本编辑器,避免系统软键盘在网页输入框获得焦点时弹出
* - 文字输入统一通过遥控网页转发
* - 暴露滚动监听,支持向下滚动自动收起工具栏,向上滚动自动唤出
*/
class RemoteWebView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : WebView(context, attrs, defStyleAttr) {
/** 滚动变化监听:(scrollX, scrollY, oldScrollX, oldScrollY) */
var onScrollChangeListener: ((Int, Int, Int, Int) -> Unit)? = null
override fun onCheckIsTextEditor(): Boolean {
// 告诉系统这不是文本编辑控件,避免为网页内的 input/textarea 弹出软键盘
return false
}
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
super.onScrollChanged(l, t, oldl, oldt)
onScrollChangeListener?.invoke(l, t, oldl, oldt)
}
}
@@ -0,0 +1,102 @@
package com.tv.browser
import android.content.Context
import java.net.URLEncoder
data class SearchEngine(
val id: String,
val name: String,
val searchUrl: String
)
/**
* 搜索引擎聚合助手:
* 1. 管理默认搜索引擎(百度、必应、搜狗、360、B站、豆瓣电影等)
* 2. 智能识别输入内容是直接网址还是搜索关键词
* 3. 构造搜索 URL
*/
object SearchEngineHelper {
private const val PREFS_NAME = "tv_browser_search_engine"
private const val KEY_DEFAULT_ENGINE = "default_engine_id"
/** 伪搜索引擎:在收藏的影视站里聚合搜片(见 VodSearchHelper),没有搜索 URL */
const val VOD_ENGINE_ID = "vod"
val ENGINES = listOf(
SearchEngine(VOD_ENGINE_ID, "影视聚合(收藏站点)", ""),
SearchEngine("baidu", "百度", "https://www.baidu.com/s?wd="),
SearchEngine("bing", "必应 Bing", "https://cn.bing.com/search?q="),
SearchEngine("sogou", "搜狗", "https://www.sogou.com/web?query="),
SearchEngine("so360", "360 搜索", "https://www.so.com/s?q="),
SearchEngine("bilibili", "B站搜索", "https://search.bilibili.com/all?keyword="),
SearchEngine("douban", "豆瓣电影", "https://search.douban.com/movie/subject_search?search_text=")
)
fun getDefaultEngine(context: Context): SearchEngine {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val id = prefs.getString(KEY_DEFAULT_ENGINE, VOD_ENGINE_ID) ?: VOD_ENGINE_ID
return ENGINES.firstOrNull { it.id == id } ?: ENGINES[0]
}
fun isVodDefault(context: Context): Boolean = getDefaultEngine(context).id == VOD_ENGINE_ID
/** 真正的网页搜索引擎:默认是影视聚合时用百度 */
fun getWebEngine(context: Context): SearchEngine =
getDefaultEngine(context).takeIf { it.id != VOD_ENGINE_ID } ?: ENGINES.first { it.id == "baidu" }
fun setDefaultEngine(context: Context, engineId: String) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putString(KEY_DEFAULT_ENGINE, engineId).apply()
}
/**
* 判断用户输入是否类似 URL 网址
*/
fun isLikelyUrl(input: String): Boolean {
val trimmed = input.trim().lowercase()
if (trimmed.startsWith("http://") || trimmed.startsWith("https://") ||
trimmed.startsWith("about:") || trimmed.startsWith("file://") ||
trimmed.startsWith("javascript:")) {
return true
}
// 如果包含空格或特殊中文字符,通常是搜索关键词
if (trimmed.contains(" ") || trimmed.contains(" ")) {
return false
}
// 匹配常见顶级域名特征
val domainPattern = Regex("^[a-zA-Z0-9-]+\\.(com|cn|net|org|tv|cc|top|xyz|vip|me|site|io|app|co|icu|info|xyz)(/.*)?$")
if (domainPattern.matches(trimmed)) {
return true
}
val ipPattern = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?(/.*)?$")
return ipPattern.matches(trimmed)
}
/**
* 将输入转为最终可加载的目标 URL
*/
fun resolveUrl(context: Context, input: String): String {
val trimmed = input.trim()
if (trimmed.isEmpty()) return ""
return if (isLikelyUrl(trimmed)) {
if (!trimmed.startsWith("http://", ignoreCase = true) && !trimmed.startsWith("https://", ignoreCase = true)) {
"https://$trimmed"
} else {
trimmed
}
} else {
val engine = getWebEngine(context)
buildSearchUrl(engine, trimmed)
}
}
fun buildSearchUrl(engine: SearchEngine, keyword: String): String {
val encoded = try {
URLEncoder.encode(keyword, "UTF-8")
} catch (_: Exception) {
keyword
}
return engine.searchUrl + encoded
}
}
@@ -0,0 +1,392 @@
package com.tv.browser
import android.app.Dialog
import android.os.Bundle
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.Window
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import com.tv.browser.databinding.DialogSettingsBinding
/**
* 电视大屏专属系统设置中心:
* 采用左侧分类导航 + 右侧卡片交互的双栏布局,专为 TV 遥控器 D-Pad 导航优化。
*/
class SettingsDialog : DialogFragment() {
private var binding: DialogSettingsBinding? = null
companion object {
private const val ARG_TAB = "initial_tab"
const val TAB_PLAYBACK = 0
const val TAB_BROWSER = 1
const val TAB_SAFETY = 2
const val TAB_STORAGE = 3
const val TAB_ABOUT = 4
/** @param initialTab 打开时选中的分类([TAB_PLAYBACK] … [TAB_ABOUT]) */
fun newInstance(initialTab: Int = TAB_PLAYBACK): SettingsDialog = SettingsDialog().apply {
arguments = Bundle().apply { putInt(ARG_TAB, initialTab) }
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
return dialog
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DialogSettingsBinding.inflate(inflater, container, false)
return binding!!.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupCategoryTabs()
setupPlaybackPanel()
setupBrowserPanel()
setupSafetyPanel()
setupStoragePanel()
setupAboutPanel()
binding?.btnCloseSettings?.setOnClickListener { dismiss() }
// 选中初始分类(默认第一个)
val b = binding ?: return
val tabs = listOf(b.tabPlayback, b.tabBrowser, b.tabSafety, b.tabStorage, b.tabAbout)
val initial = (arguments?.getInt(ARG_TAB, TAB_PLAYBACK) ?: TAB_PLAYBACK).coerceIn(0, tabs.size - 1)
selectTab(initial)
tabs[initial].requestFocus()
}
override fun onStart() {
super.onStart()
(activity as? BrowserActivity)?.setCursorOverlayVisible(false, this)
dialog?.window?.apply {
// 设置大屏弹窗尺寸(占宽约 85%,高约 85%)
val width = (resources.displayMetrics.widthPixels * 0.88).toInt()
val height = (resources.displayMetrics.heightPixels * 0.88).toInt()
setLayout(width, height)
setBackgroundDrawableResource(android.R.color.transparent)
}
}
override fun onDestroyView() {
(activity as? BrowserActivity)?.setCursorOverlayVisible(true, this)
super.onDestroyView()
binding = null
}
// =========================================================================
// 1. 分类 Tab 导航与遥控器焦点联动
// =========================================================================
private fun setupCategoryTabs() {
val b = binding ?: return
val tabs = listOf(b.tabPlayback, b.tabBrowser, b.tabSafety, b.tabStorage, b.tabAbout)
tabs.forEachIndexed { index, button ->
button.setOnClickListener {
selectTab(index)
}
button.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
selectTab(index)
}
}
// 在左侧 Tab 按右方向键,跳入右侧当前面板的首个卡片
button.setOnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
focusFirstCardOfCurrentPanel(index)
true
} else {
false
}
}
}
}
private fun selectTab(index: Int) {
val b = binding ?: return
val tabs = listOf(b.tabPlayback, b.tabBrowser, b.tabSafety, b.tabStorage, b.tabAbout)
val panels = listOf(b.panelPlayback, b.panelBrowser, b.panelSafety, b.panelStorage, b.panelAbout)
tabs.forEachIndexed { i, btn ->
btn.isSelected = (i == index)
}
panels.forEachIndexed { i, panel ->
panel.visibility = if (i == index) View.VISIBLE else View.GONE
}
}
private fun focusFirstCardOfCurrentPanel(tabIndex: Int) {
val b = binding ?: return
when (tabIndex) {
0 -> b.cardAutoTakeover.requestFocus()
1 -> b.cardSearchEngine.requestFocus()
2 -> b.cardAdBlock.requestFocus()
3 -> b.cardClearCache.requestFocus()
4 -> b.cardAboutVersion.requestFocus()
}
}
/** 给右侧卡片配置按左方向键返回对应 Tab 的通用监听 */
private fun bindCardLeftKeyToTab(card: View, tabIndex: Int) {
card.setOnKeyListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
val b = binding ?: return@setOnKeyListener false
val tabs = listOf(b.tabPlayback, b.tabBrowser, b.tabSafety, b.tabStorage, b.tabAbout)
tabs.getOrNull(tabIndex)?.requestFocus()
true
} else {
false
}
}
}
// =========================================================================
// 2. 播放与解码面板
// =========================================================================
private fun setupPlaybackPanel() {
val b = binding ?: return
val ctx = requireContext()
bindCardLeftKeyToTab(b.cardAutoTakeover, 0)
bindCardLeftKeyToTab(b.cardDecoderMode, 0)
bindCardLeftKeyToTab(b.cardPreloadNext, 0)
bindCardLeftKeyToTab(b.cardPurifyPage, 0)
bindCardLeftKeyToTab(b.cardClearSkips, 0)
// 刷新初始状态
refreshPlaybackStatus()
// 1) 自动接管播放开关
b.cardAutoTakeover.setOnClickListener {
(activity as? BrowserActivity)?.toggleAutoTakeover()
refreshPlaybackStatus()
}
// 2) 解码兼容模式切换
b.cardDecoderMode.setOnClickListener {
val modes = arrayOf(
"自动(默认推荐 · 硬件优先,失败回退)",
"强制硬解(GPU加速 · 省电高效)",
"强制软解(CPU解码 · 解决花屏/黑屏/绿屏)"
)
val currentMode = AppSettings.getDecoderMode(ctx)
AlertDialog.Builder(ctx)
.setTitle("选择视频解码模式")
.setSingleChoiceItems(modes, currentMode) { dialog, which ->
AppSettings.setDecoderMode(ctx, which)
refreshPlaybackStatus()
Toast.makeText(ctx, "解码模式已设为:${AppSettings.decoderModeName(which)}", Toast.LENGTH_SHORT).show()
dialog.dismiss()
}
.setNegativeButton("取消", null)
.show()
}
// 3) 下一集预解析
b.cardPreloadNext.setOnClickListener {
val current = AppSettings.isPreloadNext(ctx)
val newState = !current
AppSettings.setPreloadNext(ctx, newState)
refreshPlaybackStatus()
Toast.makeText(
ctx,
if (newState) "已开启下一集后台预解析" else "已关闭下一集预解析",
Toast.LENGTH_SHORT
).show()
}
// 4) 净化播放页
b.cardPurifyPage.setOnClickListener {
(activity as? BrowserActivity)?.togglePurifyPage()
refreshPlaybackStatus()
}
// 5) 清空片头片尾记忆
b.cardClearSkips.setOnClickListener {
AlertDialog.Builder(ctx)
.setTitle("重置片头片尾记忆")
.setMessage("确定要清除所有连续剧长按快进记住的跳过片头/片尾时长吗?")
.setPositiveButton("重置") { _, _ ->
AppSettings.clearAllSkips(ctx)
Toast.makeText(ctx, "已重置所有剧集的片头片尾跳过记忆", Toast.LENGTH_SHORT).show()
}
.setNegativeButton("取消", null)
.show()
}
}
private fun refreshPlaybackStatus() {
val b = binding ?: return
val ctx = context ?: return
b.tvAutoTakeoverStatus.text = if (AppSettings.isAutoTakeover(ctx)) "已开启" else "已关闭"
b.tvDecoderStatus.text = AppSettings.decoderModeName(AppSettings.getDecoderMode(ctx))
b.tvPreloadStatus.text = if (AppSettings.isPreloadNext(ctx)) "已开启" else "已关闭"
b.tvPurifyStatus.text = if (AppSettings.isPurifyPage(ctx)) "已开启" else "已关闭"
}
// =========================================================================
// 3. 浏览与搜索面板
// =========================================================================
private fun setupBrowserPanel() {
val b = binding ?: return
val act = activity as? BrowserActivity
bindCardLeftKeyToTab(b.cardSearchEngine, 1)
bindCardLeftKeyToTab(b.cardUserAgent, 1)
bindCardLeftKeyToTab(b.cardZoom, 1)
bindCardLeftKeyToTab(b.cardMouseMode, 1)
refreshBrowserStatus()
// 1) 搜索引擎
b.cardSearchEngine.setOnClickListener {
act?.showSearchEngineDialog { refreshBrowserStatus() }
}
// 2) UA
b.cardUserAgent.setOnClickListener {
act?.toggleUserAgent()
refreshBrowserStatus()
}
// 3) 缩放比例
b.cardZoom.setOnClickListener {
act?.showZoomDialog { refreshBrowserStatus() }
}
// 4) 鼠标模式
b.cardMouseMode.setOnClickListener {
act?.toggleMouseMode()
refreshBrowserStatus()
}
}
private fun refreshBrowserStatus() {
val b = binding ?: return
val ctx = context ?: return
val act = activity as? BrowserActivity
b.tvEngineStatus.text = SearchEngineHelper.getDefaultEngine(ctx).name
b.tvUaStatus.text = if (act?.isDesktopUa == true) "电脑桌面版" else "移动手机版"
b.tvZoomStatus.text = "${act?.currentZoomLevel ?: 100}%"
b.tvMouseModeStatus.text = if (act?.isMouseMode == true) "已开启" else "已关闭"
}
// =========================================================================
// 4. 广告与安全面板
// =========================================================================
private fun setupSafetyPanel() {
val b = binding ?: return
val act = activity as? BrowserActivity
bindCardLeftKeyToTab(b.cardAdBlock, 2)
bindCardLeftKeyToTab(b.cardContentFilter, 2)
bindCardLeftKeyToTab(b.cardUserScripts, 2)
refreshSafetyStatus()
// 1) 广告拦截
b.cardAdBlock.setOnClickListener {
act?.toggleAdBlock()
refreshSafetyStatus()
}
// 2) 内容净化/动图过滤
b.cardContentFilter.setOnClickListener {
act?.showContentFilterDialog()
}
// 3) 用户脚本
b.cardUserScripts.setOnClickListener {
act?.showUserScriptsDialog()
}
}
private fun refreshSafetyStatus() {
val b = binding ?: return
val ctx = context ?: return
b.tvAdBlockStatus.text = if (AdBlockHelper.isEnabled(ctx)) "已开启" else "已关闭"
val strong = ContentFilterHelper.isStrongFilterEnabled(ctx)
val noImage = ContentFilterHelper.isNoImageMode(ctx)
b.tvFilterStatus.text = when {
noImage -> "无图模式"
strong -> "强力过滤"
else -> "基础防护"
}
}
// =========================================================================
// 5. 存储与网络面板
// =========================================================================
private fun setupStoragePanel() {
val b = binding ?: return
val ctx = requireContext()
val act = activity as? BrowserActivity
bindCardLeftKeyToTab(b.cardClearCache, 3)
bindCardLeftKeyToTab(b.cardClearHistory, 3)
bindCardLeftKeyToTab(b.cardNetworkDiag, 3)
// 1) 清除缓存
b.cardClearCache.setOnClickListener {
act?.clearBrowserCache(showToast = true)
}
// 2) 清空播放历史与追剧记录
b.cardClearHistory.setOnClickListener {
AlertDialog.Builder(ctx)
.setTitle("清空播放历史与追剧记录")
.setMessage("确定清空所有网页视频播放进度及继续观看卡片吗?此操作无法撤销。")
.setPositiveButton("清空") { _, _ ->
act?.playbackHistoryRepository?.clearAll()
HistoryRepository(ctx).clear()
act?.binding?.layoutContinueWatching?.visibility = View.GONE
Toast.makeText(ctx, "播放历史与追剧记录已全部清空", Toast.LENGTH_SHORT).show()
}
.setNegativeButton("取消", null)
.show()
}
// 3) 网络诊断
b.cardNetworkDiag.setOnClickListener {
act?.checkNetworkDiagnostics()
}
}
// =========================================================================
// 6. 关于与按键面板
// =========================================================================
private fun setupAboutPanel() {
val b = binding ?: return
val act = activity as? BrowserActivity
bindCardLeftKeyToTab(b.cardAboutVersion, 4)
bindCardLeftKeyToTab(b.cardKeyGuide, 4)
bindCardLeftKeyToTab(b.cardRemoteServer, 4)
b.tvVersionDetail.text = "版本: v${BuildConfig.VERSION_NAME} (Release) · 引擎: Chromium WebView + ExoPlayer Media3"
b.cardRemoteServer.setOnClickListener {
act?.showRemoteControlDialog()
}
}
}
@@ -0,0 +1,71 @@
package com.tv.browser
import android.app.Application
import android.os.Build
import android.util.Log
import java.io.File
import java.io.FileWriter
import java.io.PrintWriter
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 应用入口。安装全局未捕获异常处理器,在崩溃时将日志写入文件。
*/
class TVBrowserApplication : Application() {
override fun onCreate() {
super.onCreate()
installCrashHandler()
}
private fun installCrashHandler() {
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
try {
saveCrashLog(thread, throwable)
} catch (e: Throwable) {
Log.e(TAG, "保存崩溃日志失败", e)
}
defaultHandler?.uncaughtException(thread, throwable)
}
}
private fun saveCrashLog(thread: Thread, throwable: Throwable) {
val dir = File(filesDir, CRASH_LOG_DIR)
if (!dir.exists()) dir.mkdirs()
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US)
val fileName = "crash_${dateFormat.format(Date())}.txt"
val file = File(dir, fileName)
FileWriter(file, Charsets.UTF_8).use { writer ->
val pw = PrintWriter(writer)
pw.println("=== 崩溃日志 ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())} ===")
pw.println()
pw.println("线程: ${thread.name}")
pw.println("异常: ${throwable.javaClass.name}")
pw.println("消息: ${throwable.message}")
pw.println()
pw.println("设备: ${Build.MANUFACTURER} ${Build.MODEL}")
pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
pw.println()
pw.println("--- 堆栈 ---")
throwable.printStackTrace(pw)
var cause = throwable.cause
while (cause != null) {
pw.println()
pw.println("Caused by: ${cause.javaClass.name}: ${cause.message}")
cause.printStackTrace(pw)
cause = cause.cause
}
pw.flush()
}
Log.w(TAG, "崩溃日志已保存: ${file.absolutePath}")
}
companion object {
private const val TAG = "TVBrowser.Crash"
/** 崩溃日志所在子目录,可通过 Context.filesDir/CRASH_LOG_DIR 访问 */
const val CRASH_LOG_DIR = "crash_logs"
}
}
@@ -0,0 +1,174 @@
package com.tv.browser
import android.util.Log
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.webkit.ScriptHandler
import androidx.webkit.UserAgentMetadata
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
/**
* 浏览器标识(手机版 / 电脑版)。
*
* 只换 UA 字符串对大多数站点没有效果:
* 1. 响应式站点(`<meta name="viewport" content="width=device-width">`)按视口宽度排版,与 UA 无关;
* 2. 新版 WebView 还会发送 UA Client Hints(Sec-CH-UA-Mobile / Sec-CH-UA-Platform、navigator.userAgentData),
* 不同步的话仍会报告「Android 手机」。
*
* 因此电脑版模式参照 Chrome「桌面版网站」:UA + Client Hints 一起切换,并在主页面强制使用宽视口。
*/
object UserAgentHelper {
private const val TAG = "TVBrowser.UA"
/** 电脑版强制的最小布局宽度(CSS px),大于常见桌面断点 1200 */
private const val DESKTOP_VIEWPORT_WIDTH = 1280
fun userAgentFor(desktop: Boolean): String =
if (desktop) BrowserActivity.DESKTOP_UA else BrowserActivity.MOBILE_UA
/**
* 设置 UA 字符串 + Client Hints(WebView 不支持 Client Hints 覆盖时只设字符串)。
* 不处理视口,主浏览器另需调用 [setDesktopViewport]。
*/
fun applyUserAgent(settings: WebSettings, desktop: Boolean) {
settings.userAgentString = userAgentFor(desktop)
applyMetadata(settings, desktop)
}
private fun applyMetadata(settings: WebSettings, desktop: Boolean) {
val supported = try {
WebViewFeature.isFeatureSupported(WebViewFeature.USER_AGENT_METADATA)
} catch (_: Throwable) {
false
}
if (!supported) {
Log.d(TAG, "USER_AGENT_METADATA not supported, only UA string applied")
return
}
try {
WebSettingsCompat.setUserAgentMetadata(settings, buildMetadata(desktop))
} catch (e: Throwable) {
Log.w(TAG, "setUserAgentMetadata failed", e)
}
}
/** 与 [BrowserActivity.MOBILE_UA] / [BrowserActivity.DESKTOP_UA] 保持一致的 Client Hints */
private fun buildMetadata(desktop: Boolean): UserAgentMetadata {
val ua = userAgentFor(desktop)
val major = Regex("""Chrome/(\d+)""").find(ua)?.groupValues?.get(1) ?: "120"
val full = "$major.0.0.0"
fun brand(name: String, ver: String, fullVer: String) =
UserAgentMetadata.BrandVersion.Builder()
.setBrand(name).setMajorVersion(ver).setFullVersion(fullVer).build()
val brands = listOf(
brand("Chromium", major, full),
brand("Not(A:Brand", "24", "24.0.0.0"),
brand("Google Chrome", major, full)
)
val builder = UserAgentMetadata.Builder()
.setBrandVersionList(brands)
.setFullVersion(full)
.setModel("")
return if (desktop) {
builder.setPlatform("Windows")
.setPlatformVersion("10.0.0")
.setArchitecture("x86")
.setBitness(64)
.setWow64(false)
.setMobile(false)
.build()
} else {
builder.setPlatform("Android")
.setPlatformVersion("13.0.0")
.setMobile(true)
.build()
}
}
/**
* 电脑版视口:安装 / 移除页面执行前脚本(对之后加载的页面生效,切换后需 reload)。
* @param current 之前安装的句柄(切回手机版时移除)
* @return 新句柄;不支持页面执行前注入或手机版时返回 null(旧版 WebView 需在页面回调里 evaluateJavascript 兜底)
*/
fun setDesktopViewport(webView: WebView, desktop: Boolean, current: ScriptHandler?): ScriptHandler? {
try {
current?.remove()
} catch (e: Throwable) {
Log.w(TAG, "remove desktop viewport script failed", e)
}
if (!desktop || !VideoSnifferHelper.supportsDocumentStartScript()) return null
return try {
WebViewCompat.addDocumentStartJavaScript(webView, JS_DESKTOP_MODE, setOf("*"))
} catch (e: Throwable) {
Log.w(TAG, "addDocumentStartJavaScript(desktop viewport) failed", e)
null
}
}
/**
* 电脑版页面脚本(所有 frame):
* - navigator.platform / maxTouchPoints 与桌面 UA 保持一致(部分站点用 JS 判断是否移动端)
* - 仅主页面:把网页的 viewport 改为固定宽度,等价于 Chrome「桌面版网站」忽略 width=device-width;
* 监听后续插入 / 修改的 viewport,页面没有 viewport 时补一个
*/
val JS_DESKTOP_MODE = """
(function() {
if (window.__tvDesktopMode) return;
window.__tvDesktopMode = true;
try { Object.defineProperty(Navigator.prototype, 'platform', { get: function() { return 'Win32'; }, configurable: true }); } catch (e) {}
try { Object.defineProperty(Navigator.prototype, 'maxTouchPoints', { get: function() { return 0; }, configurable: true }); } catch (e) {}
if (window.top !== window) return;
var width = Math.max($DESKTOP_VIEWPORT_WIDTH, (window.screen && window.screen.width) || 0);
var VP = 'width=' + width;
function isViewport(n) {
return n && n.nodeType === 1 && n.tagName === 'META' &&
(n.getAttribute('name') || '').toLowerCase() === 'viewport';
}
function fix(n) {
if (isViewport(n) && n.getAttribute('content') !== VP) n.setAttribute('content', VP);
}
function fixAll() {
var ms = document.getElementsByTagName('meta');
var found = false;
for (var i = 0; i < ms.length; i++) { if (isViewport(ms[i])) { found = true; fix(ms[i]); } }
return found;
}
try {
new MutationObserver(function(list) {
for (var i = 0; i < list.length; i++) {
var r = list[i];
if (r.type === 'attributes') { fix(r.target); continue; }
var added = r.addedNodes;
for (var j = 0; j < added.length; j++) {
var n = added[j];
if (n.nodeType !== 1) continue;
if (n.tagName === 'META') fix(n);
else if (n.tagName === 'HEAD' && n.getElementsByTagName) {
var ms = n.getElementsByTagName('meta');
for (var k = 0; k < ms.length; k++) fix(ms[k]);
}
}
}
}).observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['content', 'name'] });
} catch (e) {}
function ensure() {
if (fixAll()) return;
try {
var m = document.createElement('meta');
m.setAttribute('name', 'viewport');
m.setAttribute('content', VP);
(document.head || document.documentElement).appendChild(m);
} catch (e) {}
}
if (document.readyState === 'loading') {
fixAll();
document.addEventListener('DOMContentLoaded', ensure);
} else {
ensure();
}
})();
""".trimIndent()
}
@@ -0,0 +1,25 @@
package com.tv.browser
/**
* 油猴式用户脚本:在匹配的页面加载完成后注入自定义 JS。
* @param id 唯一标识(用于存储与删除)
* @param name 显示名称
* @param urlPattern 网址匹配:* 表示所有页面;否则为包含匹配(当前 URL 包含该字符串时注入)
* @param script 要注入的 JavaScript 代码
* @param enabled 是否启用
*/
data class UserScript(
val id: String,
val name: String,
val urlPattern: String,
val script: String,
val enabled: Boolean = true
) {
/** 当前 URL 是否匹配此脚本的 urlPattern */
fun matchesUrl(currentUrl: String): Boolean {
if (currentUrl.isBlank()) return false
val pattern = urlPattern.trim()
if (pattern == "*" || pattern.isEmpty()) return true
return currentUrl.contains(pattern, ignoreCase = true)
}
}
@@ -0,0 +1,47 @@
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.ItemUserScriptBinding
/**
* 用户脚本列表适配器:名称、匹配规则、启用开关、移除。
*/
class UserScriptAdapter(
private val context: Context,
private var list: List<UserScript>,
private val onToggleEnabled: (UserScript, Boolean) -> Unit,
private val onRemove: (UserScript) -> Unit,
private val onItemClick: (UserScript) -> Unit
) : BaseAdapter() {
fun updateList(newList: List<UserScript>) {
list = newList
notifyDataSetChanged()
}
override fun getCount(): Int = list.size
override fun getItem(position: Int): UserScript = 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) {
ItemUserScriptBinding.inflate(LayoutInflater.from(context), parent, false)
} else {
ItemUserScriptBinding.bind(convertView)
}
val item = getItem(position)
binding.scriptName.text = if (item.name.isNotBlank()) item.name else context.getString(R.string.user_script_code_hint)
binding.scriptPattern.text = item.urlPattern.ifBlank { "*" }
binding.scriptEnabled.isChecked = item.enabled
binding.scriptEnabled.setOnCheckedChangeListener { _, isChecked ->
onToggleEnabled(item, isChecked)
}
binding.root.setOnClickListener { onItemClick(item) }
binding.btnRemove.setOnClickListener { onRemove(item) }
return binding.root
}
}
@@ -0,0 +1,94 @@
package com.tv.browser
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
/**
* 用户脚本持久化:SharedPreferences + JSON。
*/
class UserScriptRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val keyScripts = "user_scripts"
fun getAll(): List<UserScript> {
val json = prefs.getString(keyScripts, null)
if (json.isNullOrBlank()) return emptyList()
return try {
val arr = JSONArray(json)
List(arr.length()) { i ->
val obj = arr.getJSONObject(i)
UserScript(
id = obj.optString(KEY_ID, UUID.randomUUID().toString()),
name = obj.optString(KEY_NAME, ""),
urlPattern = obj.optString(KEY_URL_PATTERN, "*"),
script = obj.optString(KEY_SCRIPT, ""),
enabled = obj.optBoolean(KEY_ENABLED, true)
)
}
} catch (_: Exception) {
emptyList()
}
}
fun getEnabledForUrl(url: String): List<UserScript> =
getAll().filter { it.enabled && it.script.isNotBlank() && it.matchesUrl(url) }
fun add(script: UserScript) {
val list = getAll().toMutableList()
val newId = script.id.ifBlank { UUID.randomUUID().toString() }
list.removeAll { it.id == newId }
list.add(0, script.copy(id = newId))
save(list)
}
fun update(script: UserScript) {
val list = getAll().toMutableList()
val idx = list.indexOfFirst { it.id == script.id }
if (idx >= 0) {
list[idx] = script
save(list)
} else {
add(script)
}
}
fun setEnabled(id: String, enabled: Boolean) {
val list = getAll().toMutableList()
val idx = list.indexOfFirst { it.id == id }
if (idx >= 0) {
list[idx] = list[idx].copy(enabled = enabled)
save(list)
}
}
fun remove(id: String) {
save(getAll().filter { it.id != id })
}
private fun save(list: List<UserScript>) {
val arr = JSONArray()
list.forEach { s ->
arr.put(JSONObject().apply {
put(KEY_ID, s.id)
put(KEY_NAME, s.name)
put(KEY_URL_PATTERN, s.urlPattern)
put(KEY_SCRIPT, s.script)
put(KEY_ENABLED, s.enabled)
})
}
prefs.edit().putString(keyScripts, arr.toString()).apply()
}
companion object {
private const val PREFS_NAME = "tv_browser_user_scripts"
private const val KEY_ID = "id"
private const val KEY_NAME = "name"
private const val KEY_URL_PATTERN = "urlPattern"
private const val KEY_SCRIPT = "script"
private const val KEY_ENABLED = "enabled"
}
}
@@ -0,0 +1,121 @@
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.DialogUserScriptsBinding
/**
* 用户脚本管理对话框:列表、添加、编辑、启用/禁用、删除,适配 TV 焦点。
*/
class UserScriptsDialog : DialogFragment() {
/** 弹窗关闭时回调 */
var onDismissCallback: (() -> Unit)? = null
private val repo: UserScriptRepository by lazy {
UserScriptRepository(requireContext())
}
private var binding: DialogUserScriptsBinding? = null
private var adapter: UserScriptAdapter? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
binding = DialogUserScriptsBinding.inflate(LayoutInflater.from(requireContext()))
val view = binding!!.root
adapter = UserScriptAdapter(
context = requireContext(),
list = repo.getAll(),
onToggleEnabled = { script, enabled ->
repo.setEnabled(script.id, enabled)
refreshList()
},
onRemove = { script ->
repo.remove(script.id)
refreshList()
Toast.makeText(requireContext(), getString(R.string.bookmark_removed), Toast.LENGTH_SHORT).show()
},
onItemClick = { script ->
showAddOrEditDialog(script)
}
)
binding!!.scriptList.adapter = adapter
binding!!.btnAdd.setOnClickListener { showAddOrEditDialog(null) }
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?.scriptList?.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 showAddOrEditDialog(existing: UserScript?) {
val addBinding = com.tv.browser.databinding.DialogAddUserScriptBinding.inflate(LayoutInflater.from(requireContext()))
addBinding.scriptName.setText(existing?.name ?: "")
addBinding.scriptPattern.setText(existing?.urlPattern?.takeIf { it != "*" } ?: "")
addBinding.scriptCode.setText(existing?.script ?: "")
val addDialog = AlertDialog.Builder(requireContext())
.setView(addBinding.root)
.create()
addDialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
addBinding.btnSave.setOnClickListener {
val name = addBinding.scriptName.text.toString().trim()
val pattern = addBinding.scriptPattern.text.toString().trim().ifBlank { "*" }
val code = addBinding.scriptCode.text.toString().trim()
if (code.isBlank()) {
Toast.makeText(requireContext(), getString(R.string.user_script_code_hint), Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
val script = UserScript(
id = existing?.id ?: java.util.UUID.randomUUID().toString(),
name = name.ifBlank { "脚本" },
urlPattern = pattern,
script = code,
enabled = existing?.enabled ?: true
)
repo.add(script)
addDialog.dismiss()
refreshList()
Toast.makeText(requireContext(), getString(R.string.user_script_saved), Toast.LENGTH_SHORT).show()
}
addBinding.btnCancel.setOnClickListener { addDialog.dismiss() }
addDialog.show()
}
private fun refreshList() {
val list = repo.getAll()
adapter?.updateList(list)
binding?.let { b ->
b.scriptList.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
}
}
}
@@ -0,0 +1,215 @@
package com.tv.browser
/**
* 用于在 WebView 中检测和控制 HTML5 <video> 的 JavaScript 片段。
* 在主文档及同源 iframe 中递归查找 video(含 Artplayer 等使用的 video.art-video)。
*/
object VideoControlHelper {
/** 在 doc 及同源 iframe 中递归查找第一个 video 元素 */
private const val FIND_VIDEO_FN = """
function __tvFindVideo(doc){
if(!doc) return null;
var v = doc.querySelector('video');
if(v) return v;
v = doc.querySelector('video.art-video');
if(v) return v;
v = doc.querySelector('.art-video-player video');
if(v) return v;
var iframes = doc.querySelectorAll('iframe');
for(var i=0;i<iframes.length;i++){
try{
var idoc = iframes[i].contentDocument;
if(idoc){ v = __tvFindVideo(idoc); if(v) return v; }
}catch(e){}
}
return null;
}
"""
/** 检测页面是否存在 video 元素(主文档 + iframe),返回 "yes" 或 "no";内部 console.log 便于 logcat 排查 */
val JS_HAS_VIDEO =
"(function(){ $FIND_VIDEO_FN var r=__tvFindVideo(document)?'yes':'no'; try{ console.log('TVBrowser findVideo:', r); }catch(e){} return r; })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 播放/暂停切换,返回 "playing" | "paused" | "no" */
val JS_TOGGLE_PLAY =
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; if(v.paused){ v.play(); return 'playing'; } else { v.pause(); return 'paused'; } })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 后退 10 秒,返回 "ok" | "no" */
val JS_SEEK_BACK =
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; v.currentTime=Math.max(0,v.currentTime-10); return 'ok'; })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 前进 10 秒,返回 "ok" | "no" */
val JS_SEEK_FORWARD =
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; v.currentTime=Math.min(v.duration||0,v.currentTime+10); return 'ok'; })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 音量减,返回 0~1 数字或 "no" */
val JS_VOLUME_DOWN =
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; v.volume=Math.max(0,v.volume-0.2); return v.volume; })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 音量加,返回 0~1 数字或 "no" */
val JS_VOLUME_UP =
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; v.volume=Math.min(1,v.volume+0.2); return v.volume; })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 请求全屏,返回 "ok" | "no" | "unsupported";捕获 Promise 避免 Uncaught Fullscreen is not supported */
val JS_FULLSCREEN =
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; var fn=v.requestFullscreen||v.webkitRequestFullscreen; if(!fn) return 'unsupported'; try { var p=fn.call(v); if(p&&p.catch) p.catch(function(){}); return 'ok'; } catch(e) { return 'unsupported'; } })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 在 video 上模拟双击(带中心坐标),触发与用户双击视频相同的原生全屏(onShowCustomView),返回 "ok" | "no" */
val JS_FULLSCREEN_DBLCLICK =
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; try { var r=v.getBoundingClientRect(); var x=r.left+r.width/2, y=r.top+r.height/2; var opts={bubbles:true,cancelable:true,view:window,clientX:x,clientY:y}; v.dispatchEvent(new MouseEvent('dblclick',opts)); return 'ok'; } catch(err) { return 'no'; } })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 获取当前播放状态,返回 "playing" | "paused" | "no" */
val JS_GET_STATE =
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); return v ? (v.paused ? 'paused' : 'playing') : 'no'; })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 获取当前 video 的 src/currentSrc,返回 URL 或空;blob: 需在页内播,新页面不可用 */
val JS_GET_VIDEO_SRC =
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return ''; var s=(v.currentSrc||v.src||'').trim(); return s; })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 从页内 application/ld+json 中取 VideoObject.contentUrl(m3u8/mp4 等),返回 URL 或空 */
val JS_GET_CONTENT_URL = """
(function(){
var scripts=document.querySelectorAll('script[type="application/ld+json"]');
for(var i=0;i<scripts.length;i++){
try{
var o=JSON.parse(scripts[i].textContent);
var u=o&&(o.contentUrl||(o['@type']==='VideoObject'&&o.contentUrl));
if(u&&typeof u==='string'&&u.indexOf('http')===0) return u;
if(Array.isArray(o)) for(var j=0;j<o.length;j++){
var v=o[j];
if(v&&v['@type']==='VideoObject'&&v.contentUrl) return v.contentUrl;
}
}catch(e){}
}
return '';
})();
""".trimIndent()
/**
* 全屏增强注入脚本:
* 1. 强制为所有 <iframe> 授予 allowfullscreen 属性及 Permissions Policy,解决聚合站 iframe 视频全屏被拦截的问题
* 2. 模拟 document.fullscreenEnabled = true,防止网页播放器因检测不支持全屏而禁用全屏按钮
* 3. 拦截 Element.prototype.requestFullscreen,失败时自动通知原生 Bridge 进行应用级全屏兜底
* 4. 监听常见网页播放器的“网页全屏”样式(如 Artplayer、DPlayer、Bilibili),联动隐藏电视工具栏
*/
val JS_ENABLE_FULLSCREEN_ENHANCEMENT = """
(function() {
try {
if (window.__tvFullscreenHelperInjected) return;
window.__tvFullscreenHelperInjected = true;
try {
Object.defineProperty(document, 'fullscreenEnabled', { get: function() { return true; }, configurable: true });
Object.defineProperty(document, 'webkitFullscreenEnabled', { get: function() { return true; }, configurable: true });
} catch(e) {}
function patchIframes() {
var iframes = document.querySelectorAll('iframe');
for (var i = 0; i < iframes.length; i++) {
var f = iframes[i];
if (!f.hasAttribute('allowfullscreen')) f.setAttribute('allowfullscreen', 'true');
if (!f.hasAttribute('webkitallowfullscreen')) f.setAttribute('webkitallowfullscreen', 'true');
if (!f.hasAttribute('mozallowfullscreen')) f.setAttribute('mozallowfullscreen', 'true');
var allow = f.getAttribute('allow') || '';
if (allow.indexOf('fullscreen') === -1) {
f.setAttribute('allow', (allow ? allow + '; ' : '') + 'fullscreen *; autoplay *; encrypted-media *');
}
}
}
patchIframes();
try {
new MutationObserver(patchIframes).observe(document.documentElement || document.body, { childList: true, subtree: true });
} catch(e) {}
var origReq = Element.prototype.requestFullscreen || Element.prototype.webkitRequestFullscreen;
function enhancedRequestFullscreen(options) {
var target = this;
if (window.AndroidFullscreenBridge) {
window.AndroidFullscreenBridge.onFullscreenAttempted();
}
var p = null;
if (origReq) {
try {
p = origReq.call(target, options);
} catch(err) {
if (window.AndroidFullscreenBridge) window.AndroidFullscreenBridge.onEnterFullscreen(false);
}
}
if (p && p.catch) {
p.catch(function(err) {
if (window.AndroidFullscreenBridge) window.AndroidFullscreenBridge.onEnterFullscreen(false);
});
}
return p || Promise.resolve();
}
try {
Element.prototype.requestFullscreen = enhancedRequestFullscreen;
Element.prototype.webkitRequestFullscreen = enhancedRequestFullscreen;
} catch(e) {}
var lastWebFullscreen = false;
function checkWebFullscreen() {
var isWebFs = !!(
document.querySelector('.art-fullscreen-web') ||
document.querySelector('.dplayer-fulled') ||
document.querySelector('.bpx-state-web-fullscreen') ||
document.querySelector('[data-fullscreen="web"]') ||
document.querySelector('.web-fullscreen') ||
document.querySelector('.fullscreen-web')
);
if (isWebFs !== lastWebFullscreen) {
lastWebFullscreen = isWebFs;
if (window.AndroidFullscreenBridge) {
if (isWebFs) {
window.AndroidFullscreenBridge.onEnterFullscreen(false);
} else {
window.AndroidFullscreenBridge.onExitFullscreen();
}
}
}
}
try {
new MutationObserver(checkWebFullscreen).observe(document.documentElement || document.body, {
attributes: true, subtree: true, attributeFilter: ['class', 'style']
});
} catch(e) {}
document.addEventListener('fullscreenchange', function() {
if (!document.fullscreenElement && window.AndroidFullscreenBridge) {
window.AndroidFullscreenBridge.onExitFullscreen();
}
});
document.addEventListener('webkitfullscreenchange', function() {
if (!document.webkitFullscreenElement && window.AndroidFullscreenBridge) {
window.AndroidFullscreenBridge.onExitFullscreen();
}
});
} catch(e) {}
})();
""".trimIndent()
/** 退出全屏 JS 片段 */
val JS_EXIT_FULLSCREEN = """
(function(){
try {
if (document.exitFullscreen) document.exitFullscreen();
else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
} catch(e) {}
try {
var ev = new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, which: 27, bubbles: true });
document.dispatchEvent(ev);
} catch(e) {}
})();
""".trimIndent()
}
@@ -0,0 +1,271 @@
package com.tv.browser
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.net.http.SslError
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.FrameLayout
/**
* 后台解析器:用一个隐藏的 WebView 打开剧集播放页,借助页面执行前 Hook + 网络嗅探拿到真实视频地址。
* 播放器据此可以在不离开全屏的情况下切换选集 / 自动下一集 / 从历史记录续播。
*
* - 静音解析:mediaPlaybackRequiresUserGesture = true,网页播放器不会出声,但 hls.js 依然会请求 m3u8
* - 每次解析新建 WebView,旧页面的回调不会串到新请求
*/
class VideoResolver(
private val context: Context,
/** 承载隐藏 WebView 的容器(需已挂到窗口上,1px 大小即可) */
private val container: ViewGroup,
private val userAgent: String
) {
interface Callback {
fun onResolved(source: VideoSource, siteInfo: SiteInfo?)
fun onFailed(reason: String)
}
private data class Candidate(val url: String, val kind: String, val frameUrl: String?, val title: String)
private val handler = Handler(Looper.getMainLooper())
private val headerCache = VideoSnifferHelper.RequestHeaderCache()
private var webView: WebView? = null
private var callback: Callback? = null
private var pageUrl: String = ""
private var wantSiteInfo = false
private var siteInfo: SiteInfo? = null
private val candidates = mutableListOf<Candidate>()
private var finished = true
private var generation = 0
private var hasDocStartHook = false
private var playlistWaitMs = 0
private val timeoutRunnable = Runnable {
if (!finished) {
if (candidates.isNotEmpty()) finalizeNow() else fail("解析超时,未找到可播放的视频")
}
}
private val finalizeRunnable = Runnable { finalizeNow() }
/**
* 解析指定播放页。
* @param needSiteInfo 是否需要同时提取选集信息(例如从历史记录续播时还不知道选集)
*/
fun resolve(pageUrl: String, needSiteInfo: Boolean, callback: Callback, timeoutMs: Long = 25_000) {
cancel()
this.pageUrl = pageUrl
this.callback = callback
this.wantSiteInfo = needSiteInfo
this.siteInfo = null
this.finished = false
candidates.clear()
headerCache.clear()
playlistWaitMs = 0
generation++
val wv = createWebView(generation)
webView = wv
Log.d(TAG, "resolve: $pageUrl (docStartHook=$hasDocStartHook)")
wv.loadUrl(pageUrl)
handler.postDelayed(timeoutRunnable, timeoutMs)
}
fun cancel() {
finished = true
callback = null
handler.removeCallbacks(timeoutRunnable)
handler.removeCallbacks(finalizeRunnable)
destroyWebView()
}
fun destroy() = cancel()
@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
private fun createWebView(gen: Int): WebView {
val wv = WebView(context)
wv.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
userAgentString = userAgent
mediaPlaybackRequiresUserGesture = true // 静音解析:网页播放器不自动出声
blockNetworkImage = true // 不加载图片,加快解析
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
setSupportMultipleWindows(false)
javaScriptCanOpenWindowsAutomatically = false
}
// Client Hints 与 UA 字符串保持一致(与主浏览器同一身份)
UserAgentHelper.applyUserAgent(wv.settings, userAgent == BrowserActivity.DESKTOP_UA)
wv.settings.userAgentString = userAgent
wv.addJavascriptInterface(Bridge(gen), "AndroidVideoBridge")
hasDocStartHook = VideoSnifferHelper.installDocumentStartHook(wv)
wv.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url?.toString() ?: return true
return AdBlockHelper.isHijackScheme(url) || !(url.startsWith("http://") || url.startsWith("https://"))
}
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
val url = request?.url?.toString() ?: return null
headerCache.put(url, request.requestHeaders)
if (AdBlockHelper.isEnabled(context) && AdBlockHelper.shouldBlockUrl(url)) {
return AdBlockHelper.createEmptyResponse()
}
if (VideoSnifferHelper.isVideoStreamUrl(url)) {
handler.post { onCandidate(gen, url, "net", null, "") }
}
// 解析只需要清单与请求头:不下载分片,避免隐藏网页的播放器在后台缓冲,和正在播放的视频抢网速
if (!request.isForMainFrame && VideoSnifferHelper.isMediaSegmentUrl(url)) {
return VideoSnifferHelper.createRejectedResponse()
}
return null
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null)
}
override fun onPageFinished(view: WebView?, url: String?) {
if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null)
view?.evaluateJavascript(VideoSnifferHelper.JS_SITE_ADAPTER_MACCMS, null)
view?.evaluateJavascript(VideoSnifferHelper.JS_SNIFF_VIDEO, null)
}
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
handler?.proceed() // 与浏览器主界面一致:电视系统时间不准时放行
}
}
// 1x1 挂到窗口上:部分内核对未挂载的 WebView 会暂停计时器,导致播放器脚本不执行
container.addView(wv, FrameLayout.LayoutParams(1, 1))
return wv
}
private fun destroyWebView() {
val wv = webView ?: return
webView = null
try {
wv.stopLoading()
container.removeView(wv)
wv.destroy()
} catch (_: Exception) { }
}
private inner class Bridge(private val gen: Int) {
@JavascriptInterface
fun onMediaRequest(url: String, kind: String, frameUrl: String, title: String) {
handler.post { onCandidate(gen, url, kind, frameUrl, title) }
}
@JavascriptInterface
fun onVideoFound(url: String, title: String) {
handler.post { onCandidate(gen, url, "dom", null, title) }
}
@JavascriptInterface
fun onPlaylist(url: String, finalUrl: String, text: String) {
PlaylistCache.put(url, finalUrl, text)
Log.d(TAG, "playlist captured: $url (${text.length} chars)")
}
@JavascriptInterface
fun onSiteInfo(json: String) {
handler.post {
if (gen != generation) return@post
SiteInfo.fromJson(json)?.let { siteInfo = it }
}
}
// 主界面 Hook 里可能调用的其它接口,这里空实现避免 JS 报错
@JavascriptInterface
fun onEnterFullscreen(isNative: Boolean) {}
}
private fun onCandidate(gen: Int, url: String, kind: String, frameUrl: String?, title: String) {
if (gen != generation || finished) return
if (!url.startsWith("http")) return
if (VideoSnifferHelper.isWebOnlyStream(url)) {
Log.d(TAG, "web-only stream: $url")
fail(VideoSnifferHelper.WEB_ONLY_MESSAGE)
return
}
if (AdBlockHelper.isEnabled(context) && AdBlockHelper.shouldBlockUrl(url)) return
if (candidates.any { it.url == url || VideoSnifferHelper.isLikelyVariantOf(url, it.url) }) return
candidates.add(Candidate(url, kind, frameUrl, title))
Log.d(TAG, "candidate[$kind]: $url")
handler.removeCallbacks(finalizeRunnable)
if (candidates.any { VideoSnifferHelper.isHls(it.url, it.kind) }) {
// 拿到 HLS:稍等片刻让对应的网络请求头进入缓存
handler.postDelayed(finalizeRunnable, 600)
} else {
// 只有 mp4/flv(可能是贴片广告):多等一会儿看看有没有 HLS
handler.postDelayed(finalizeRunnable, 3000)
}
}
private fun finalizeNow() {
if (finished) return
val best = candidates.firstOrNull { VideoSnifferHelper.isHls(it.url, it.kind) } ?: candidates.firstOrNull()
if (best == null) {
fail("未找到可播放的视频")
return
}
// HLS:等网页的 hls.js 把清单原文回传(一次性链接只能用这份内容),最多再等约 5 秒
if (VideoSnifferHelper.isHls(best.url, best.kind) && !PlaylistCache.isReady(best.url) && playlistWaitMs < 5000) {
playlistWaitMs += 300
handler.postDelayed(finalizeRunnable, 300)
return
}
// 需要选集信息但还没拿到:主动再跑一次适配脚本,最多再等 1 秒
if (wantSiteInfo && siteInfo == null) {
wantSiteInfo = false
webView?.evaluateJavascript(VideoSnifferHelper.JS_SITE_ADAPTER_MACCMS, null)
handler.postDelayed(finalizeRunnable, 1000)
return
}
val headers = VideoSnifferHelper.buildPlaybackHeaders(
url = best.url,
captured = headerCache.get(best.url),
userAgent = userAgent,
fallbackReferer = best.frameUrl ?: pageUrl
)
val info = siteInfo
val title = info?.displayTitle()?.takeIf { it.isNotBlank() } ?: best.title
val source = VideoSource(
url = best.url,
title = title,
pageUrl = pageUrl,
headers = headers,
isHls = VideoSnifferHelper.isHls(best.url, best.kind)
)
Log.d(TAG, "resolved: ${source.url} headers=${headers.keys}")
val cb = callback
finished = true
handler.removeCallbacks(timeoutRunnable)
// 先回调再销毁(避免在 WebView 回调栈里销毁自身)
cb?.onResolved(source, info)
handler.post { if (finished) destroyWebView() }
}
private fun fail(reason: String) {
if (finished) return
val cb = callback
finished = true
handler.removeCallbacks(timeoutRunnable)
handler.removeCallbacks(finalizeRunnable)
cb?.onFailed(reason)
handler.post { if (finished) destroyWebView() }
}
companion object {
private const val TAG = "TVBrowser.Resolver"
}
}
@@ -0,0 +1,963 @@
package com.tv.browser
import android.net.Uri
import android.util.Log
import android.webkit.CookieManager
import android.webkit.WebResourceResponse
import android.webkit.WebView
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import java.io.ByteArrayInputStream
/**
* 视频流嗅探工具:
* 1. 拦截与识别网络请求中的视频流地址(.m3u8, .mp4, .flv 等)
* 2. 注入 JS 动态提取网页内的 <video> 播放源并回调宿主
* 3. 页面执行前注入 Hook(XHR / fetch / hls.js / video.src),可进入所有 iframe,抓取 blob: 背后的真实 m3u8
* 4. 记录请求头,供原生播放器原样回放(Referer / UA / Cookie)
* 5. 站点适配脚本(苹果 CMS 通用):提取片名、当前集、选集列表、下一集
*/
object VideoSnifferHelper {
private const val TAG = "TVBrowser.Sniffer"
private val VIDEO_EXTENSIONS = arrayOf(".m3u8", ".mp4", ".flv", ".f4v", ".webm")
/**
* 判断一个请求 URL 是否为多媒体视频流地址
*/
fun isVideoStreamUrl(url: String): Boolean {
val lower = url.lowercase().trim()
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
return false
}
// 排除常见的带有 mp4/m3u8 字符串的非视频文件(如 .jpg, .png, .js, .css, .html)
if (lower.endsWith(".jpg") || lower.endsWith(".png") || lower.endsWith(".gif") ||
lower.endsWith(".js") || lower.endsWith(".css") || lower.endsWith(".html") ||
lower.endsWith(".ts") || lower.endsWith(".key")) {
return false
}
// 解析 Path
val path = try {
Uri.parse(url).path?.lowercase() ?: ""
} catch (_: Exception) {
lower
}
for (ext in VIDEO_EXTENSIONS) {
if (path.endsWith(ext) || lower.contains("$ext?") || lower.contains("$ext#") || lower.endsWith(ext)) {
return true
}
}
return false
}
/** 是否 HLS:显式标记为 hls,或地址含 .m3u8 */
fun isHls(url: String, kind: String? = null): Boolean =
kind == "hls" || url.contains(".m3u8", ignoreCase = true)
private val SEGMENT_EXTENSIONS = arrayOf(".ts", ".m4s", ".aac", ".m4a")
/** 不少站点把 TS 分片伪装成图片后缀,用 XHR 加载 */
private val DISGUISED_SEGMENT_EXTENSIONS = arrayOf(".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico")
/**
* 是否像 HLS / DASH 的媒体分片(只在后台解析 WebView 里用来拦截:解析只需要清单和请求头,不需要分片)。
* 后台解析 WebView 已开启 blockNetworkImage,能走到这里的图片后缀请求基本是 XHR 加载的伪装分片。
*/
fun isMediaSegmentUrl(url: String): Boolean {
val path = try {
Uri.parse(url).path?.lowercase() ?: return false
} catch (_: Exception) {
return false
}
return SEGMENT_EXTENSIONS.any { path.endsWith(it) } || DISGUISED_SEGMENT_EXTENSIONS.any { path.endsWith(it) }
}
/** 拒绝请求(403):网页播放器会认为分片加载失败并停止,而不是拿到空数据反复解析 */
fun createRejectedResponse(): WebResourceResponse =
WebResourceResponse("text/plain", "utf-8", 403, "Blocked", emptyMap(), ByteArrayInputStream(ByteArray(0)))
/** 原生播放器无法播放时给用户的提示 */
const val WEB_ONLY_MESSAGE = "央视网视频经过加密,原生播放器会花屏,请直接在网页中播放"
/** 央视网 CNTV 的 h5e / enc / enc2 HLS 路径(普通 /asp/hls/ 不在内,但只有 270p) */
private val WEB_ONLY_PATH = Regex("""/asp/(h5e|enc2?)/""", RegexOption.IGNORE_CASE)
/**
* 只能在网页内播放的视频流:画面数据经站点私有算法混淆,由网页播放器(wasm)边播边还原,
* 原生播放器 / 外部播放器直接解码会花屏(有声音、画面马赛克)。
* 目前:央视网 CNTV 的 h5e / enc / enc2 清单(如 dh5ws01.v.cntv.cn/asp/h5e/hls/...)。
*/
fun isWebOnlyStream(url: String): Boolean {
val uri = try { Uri.parse(url.trim()) } catch (_: Exception) { return false }
val host = uri.host?.lowercase() ?: return false
if (!host.contains("cntv") && !host.contains("cctv")) return false
return WEB_ONLY_PATH.containsMatchIn(uri.path ?: "")
}
/**
* 新的 m3u8 是否只是已有 m3u8 的子清单(主清单 → 码率子清单),用于去重。
* 规则:同域名,且新地址所在目录以旧地址所在目录开头。
*/
fun isLikelyVariantOf(newUrl: String, existingUrl: String): Boolean {
if (newUrl == existingUrl) return true
if (!isHls(newUrl) || !isHls(existingUrl)) return false
return try {
val a = Uri.parse(newUrl)
val b = Uri.parse(existingUrl)
if (a.host != b.host) return false
val dirA = a.path?.substringBeforeLast('/') ?: return false
val dirB = b.path?.substringBeforeLast('/') ?: return false
dirA.startsWith(dirB)
} catch (_: Exception) {
false
}
}
// ---------------------------------------------------------------------------------------
// 请求头记录与回放
// ---------------------------------------------------------------------------------------
/** 播放时不应原样回放的请求头 */
private val SKIP_HEADERS = setOf(
"range", "accept-encoding", "host", "content-length", "connection",
"if-none-match", "if-modified-since", "upgrade-insecure-requests"
)
/**
* 线程安全的 LRU:记录 WebView 发出的请求头(shouldInterceptRequest 在后台线程回调)。
*/
class RequestHeaderCache(private val maxSize: Int = 300) {
private val map = object : LinkedHashMap<String, Map<String, String>>(64, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Map<String, String>>?): Boolean =
size > maxSize
}
@Synchronized
fun put(url: String, headers: Map<String, String>?) {
map[url] = headers ?: emptyMap()
}
@Synchronized
fun get(url: String): Map<String, String>? = map[url]
@Synchronized
fun clear() = map.clear()
}
/**
* 组装原生播放器要用的请求头:
* - 优先使用 WebView 实际发出的请求头(最能还原站点校验条件)
* - 补充 User-Agent 与 Cookie
* - 没有抓到原始请求头时,用来源页作为 Referer 兜底
*/
fun buildPlaybackHeaders(
url: String,
captured: Map<String, String>?,
userAgent: String,
fallbackReferer: String?
): Map<String, String> {
val headers = LinkedHashMap<String, String>()
captured?.forEach { (k, v) ->
val lk = k.lowercase()
if (lk !in SKIP_HEADERS && !lk.startsWith("sec-")) headers[k] = v
}
fun has(name: String) = headers.keys.any { it.equals(name, ignoreCase = true) }
if (!has("User-Agent") && userAgent.isNotBlank()) headers["User-Agent"] = userAgent
try {
val cookie = CookieManager.getInstance().getCookie(url)
if (!cookie.isNullOrBlank() && !has("Cookie")) headers["Cookie"] = cookie
} catch (_: Exception) { }
if (captured == null && !fallbackReferer.isNullOrBlank() && !has("Referer")) {
headers["Referer"] = fallbackReferer
}
return headers
}
// ---------------------------------------------------------------------------------------
// 页面执行前注入
// ---------------------------------------------------------------------------------------
/** 当前 WebView 是否支持「页面执行前 + 所有 iframe」注入 */
fun supportsDocumentStartScript(): Boolean =
try {
WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
} catch (_: Throwable) {
false
}
/**
* 安装页面执行前 Hook(对之后加载的所有页面、所有 frame 生效)。
* @return true 表示已安装;false 表示系统 WebView 太旧,调用方需在 onPageStarted 用 evaluateJavascript 兜底
*/
fun installDocumentStartHook(webView: WebView): Boolean {
if (!supportsDocumentStartScript()) return false
return try {
WebViewCompat.addDocumentStartJavaScript(webView, JS_DOCUMENT_START_HOOK, setOf("*"))
true
} catch (e: Throwable) {
Log.w(TAG, "addDocumentStartJavaScript failed", e)
false
}
}
/**
* 页面执行前 Hook:在每个 frame(含跨域 iframe)里抓取真实视频地址。
* - XHR / fetch:地址带视频后缀,或响应是 m3u8(Content-Type 含 mpegurl / 内容以 #EXTM3U 开头)
* - hls.js:拦截 Hls.prototype.loadSource,直接拿到传给 hls.js 的地址(即使没有 .m3u8 后缀)
* - video.src 赋值(排除 blob: / data:)
* 通过 AndroidVideoBridge.onMediaRequest(url, kind, frameUrl, title) 回传原生层。
* 同时监听 {__tvcmd:'pause'} / {__tvcmd:'resume'} 消息,供原生层暂停网页内所有视频并停止 / 恢复 hls.js 缓冲(含 iframe)。
*/
val JS_DOCUMENT_START_HOOK = """
(function() {
if (window.__tvSniffInstalled) return;
window.__tvSniffInstalled = true;
var reported = {};
var hlsReported = false;
var RE_MEDIA = /\.(m3u8|mp4|flv|f4v|webm)(\?|#|$)/i;
function pageTitle() {
try { if (window.top && window.top.document && window.top.document.title) return window.top.document.title; } catch (e) {}
return document.title || '';
}
function report(u, kind) {
try {
if (!u) return;
u = String(u);
if (u.indexOf('blob:') === 0 || u.indexOf('data:') === 0) return;
var abs = new URL(u, location.href).href;
if (!/^https?:/i.test(abs)) return;
if (reported[abs]) return;
reported[abs] = 1;
if (kind === 'hls' || /\.m3u8/i.test(abs)) hlsReported = true;
var b = window.AndroidVideoBridge;
if (b && b.onMediaRequest) b.onMediaRequest(abs, kind || '', location.href, pageTitle());
else if (b && b.onVideoFound) b.onVideoFound(abs, pageTitle());
} catch (e) {}
}
// 回传清单原文:很多站点的 m3u8 是一次性链接,原生播放器需直接使用网页已下载的内容
function sendPlaylist(u, finalUrl, text) {
try {
if (!u || typeof text !== 'string' || text.slice(0, 7) !== '#EXTM3U') return;
var abs = new URL(String(u), location.href).href;
var fin = finalUrl ? new URL(String(finalUrl), location.href).href : abs;
var b = window.AndroidVideoBridge;
if (b && b.onPlaylist) b.onPlaylist(abs, fin, text);
} catch (e) {}
}
try {
var XO = XMLHttpRequest.prototype.open, XS = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(m, u) { this.__tvUrl = u; return XO.apply(this, arguments); };
XMLHttpRequest.prototype.send = function() {
var x = this;
try {
if (x.__tvUrl && RE_MEDIA.test(String(x.__tvUrl))) report(x.__tvUrl, 'xhr');
x.addEventListener('load', function() {
try {
var text = ((x.responseType === '' || x.responseType === 'text') && typeof x.responseText === 'string') ? x.responseText : null;
if (text && text.slice(0, 7) === '#EXTM3U') sendPlaylist(x.__tvUrl, x.responseURL, text);
if (hlsReported) return; // 已拿到主清单,忽略后续子清单
var ct = (x.getResponseHeader('content-type') || '').toLowerCase();
var isM3u8 = ct.indexOf('mpegurl') >= 0;
if (!isM3u8 && text) isM3u8 = text.slice(0, 7) === '#EXTM3U';
if (isM3u8) report(x.responseURL || x.__tvUrl, 'hls');
} catch (e) {}
});
} catch (e) {}
return XS.apply(this, arguments);
};
} catch (e) {}
try {
if (window.fetch) {
var F = window.fetch;
window.fetch = function(input, init) {
var u = (typeof input === 'string') ? input : (input && input.url);
if (u && RE_MEDIA.test(u)) report(u, 'fetch');
var p = F.apply(this, arguments);
try {
p.then(function(r) {
try {
var ct = (r.headers.get('content-type') || '').toLowerCase();
var maybeM3u8 = ct.indexOf('mpegurl') >= 0 || /\.m3u8/i.test(String(u || '')) ||
(ct.indexOf('text/') === 0 && Number(r.headers.get('content-length') || 0) < 4194304);
if (maybeM3u8) {
r.clone().text().then(function(t) {
if (t && t.slice(0, 7) === '#EXTM3U') {
sendPlaylist(u, r.url, t);
if (!hlsReported) report(r.url || u, 'hls');
}
}, function() {});
}
} catch (e) {}
}, function() {});
} catch (e) {}
return p;
};
}
} catch (e) {}
// 记录页面里的 hls.js 实例:切到原生播放器时 stopLoad(),否则网页播放器暂停后仍会在后台继续缓冲分片,和原生播放器抢网速
var hlsList = [];
function trackHls(h) {
try {
if (!h || hlsList.indexOf(h) >= 0) return;
hlsList.push(h);
if (hlsList.length > 20) hlsList.shift();
} catch (e) {}
}
function wrapHls(H) {
try {
if (!H || !H.prototype || H.prototype.__tvWrapped) return H;
var L = H.prototype.loadSource, A = H.prototype.attachMedia, D = H.prototype.destroy;
if (typeof L === 'function') {
H.prototype.loadSource = function(src) { trackHls(this); report(src, 'hls'); return L.apply(this, arguments); };
H.prototype.__tvWrapped = true;
}
if (typeof A === 'function') {
H.prototype.attachMedia = function() { trackHls(this); return A.apply(this, arguments); };
}
if (typeof D === 'function') {
H.prototype.destroy = function() {
try { var i = hlsList.indexOf(this); if (i >= 0) hlsList.splice(i, 1); } catch (e) {}
return D.apply(this, arguments);
};
}
} catch (e) {}
return H;
}
try {
var _H = window.Hls;
if (_H) wrapHls(_H);
Object.defineProperty(window, 'Hls', {
configurable: true,
get: function() { return _H; },
set: function(v) { _H = wrapHls(v); }
});
} catch (e) {}
try {
var d = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'src');
if (d && d.set) {
Object.defineProperty(HTMLMediaElement.prototype, 'src', {
configurable: true, enumerable: d.enumerable, get: d.get,
set: function(v) { report(v, 'video'); return d.set.call(this, v); }
});
}
} catch (e) {}
try {
window.addEventListener('message', function(e) {
try {
var data = e.data;
if (!data || (data.__tvcmd !== 'pause' && data.__tvcmd !== 'resume')) return;
var i;
if (data.__tvcmd === 'pause') {
var vs = document.querySelectorAll('video,audio');
for (i = 0; i < vs.length; i++) { try { vs[i].pause(); } catch (_) {} }
// 暂停后 hls.js 仍会缓冲到上限(不少站点设到几分钟),停止加载,回到网页时再恢复
for (i = 0; i < hlsList.length; i++) {
try { if (!hlsList[i].__tvSuspended) { hlsList[i].stopLoad(); hlsList[i].__tvSuspended = true; } } catch (_) {}
}
} else {
for (i = 0; i < hlsList.length; i++) {
try { if (hlsList[i].__tvSuspended) { hlsList[i].__tvSuspended = false; hlsList[i].startLoad(); } } catch (_) {}
}
}
for (var j = 0; j < window.frames.length; j++) { try { window.frames[j].postMessage(data, '*'); } catch (_) {} }
} catch (_) {}
});
} catch (e) {}
})();
""".trimIndent()
/** 暂停网页内所有音视频(主页面 + 同源 iframe 直接暂停;跨域 iframe 通过 postMessage 交给 Hook 处理) */
val JS_PAUSE_ALL_MEDIA = """
(function() {
function pauseIn(doc) {
if (!doc) return;
var vs = doc.querySelectorAll('video,audio');
for (var i = 0; i < vs.length; i++) { try { vs[i].pause(); } catch (e) {} }
var fs = doc.querySelectorAll('iframe');
for (var k = 0; k < fs.length; k++) { try { pauseIn(fs[k].contentDocument); } catch (e) {} }
}
try { pauseIn(document); } catch (e) {}
try { window.postMessage({ __tvcmd: 'pause' }, '*'); } catch (e) {}
})();
""".trimIndent()
/** 从原生 / 外部播放器回到网页:恢复被 [JS_PAUSE_ALL_MEDIA] 停止加载的 hls.js(只恢复缓冲,不自动播放) */
const val JS_RESUME_MEDIA_LOADING = "try{window.postMessage({__tvcmd:'resume'},'*');}catch(e){}"
/**
* 站点适配:苹果 CMS(MacCMS)通用。
* 识别页面中的 player_aaaa / player_data 变量;选集列表通过「与当前播放页 URL 结构相同、只有最后一个数字不同」的链接推断,
* 适配 /vodplay/1-1-1.html、/play/1-1-1/、/index.php/vod/play/id/1/sid/1/nid/1.html 等常见规则。
* 结果通过 AndroidVideoBridge.onSiteInfo(json) 回传。
*/
val JS_SITE_ADAPTER_MACCMS = """
(function() {
try {
if (!window.AndroidVideoBridge || !window.AndroidVideoBridge.onSiteInfo) return;
function parseUrl(u) {
try {
var a = new URL(u, location.href);
a.hash = '';
return a;
} catch (e) {
return null;
}
}
function normKey(u) {
if (!u) return '';
var p = (u.pathname || '').replace(/\/+$/, '') || '/';
return (p + (u.search || '')).toLowerCase();
}
function tryDecodeB64(str) {
try {
var clean = (str || '').replace(/[^A-Za-z0-9+/=]/g, '');
if (clean.length < 4) return null;
while (clean.length % 4 !== 0) clean += '=';
var dec = window.atob(clean);
return dec && /^[A-Za-z0-9_\-\.]+$/.test(dec) ? dec : null;
} catch (e) {
return null;
}
}
var cur = parseUrl(location.href);
if (!cur) return;
var curKey = normKey(cur);
var curPath = (cur.pathname || '').replace(/\/+$/, '').toLowerCase();
var BAD = /上一|下一|上集|下集|播放|立即|观看|开始|继续|下载|APP|倒序|正序|选集|切换|收藏|分享|评论|报错|刷新|换源|排序|^(prev|previous|next|all)$/i;
/** 选集名称:保留网页原文(仅去掉图标字体字符与多余空白),网页没有名称时才兜底为「第X集」 */
function cleanName(rawText, fallbackIndex) {
var s = (rawText || '').replace(/[\uE000-\uF8FF]/g, '').replace(/\s+/g, ' ').trim();
if (!s) return '第' + (fallbackIndex != null ? fallbackIndex : 1) + '集';
return s;
}
var pd = window.player_aaaa || window.player_data || null;
// -------------------------------------------------------------
// 策略 1: 扫描页面选集 DOM 容器(通杀 4kvm、4kcz、MacCMS、SeaCMS 等绝大多数站点)
// -------------------------------------------------------------
var candidates = [];
var seenSet = [];
function addCandidate(el) {
if (!el || seenSet.indexOf(el) >= 0) return;
seenSet.push(el);
candidates.push(el);
}
var SELECTORS = [
'.anthology-list-play', '.anthology-list',
'.content_playlist', '.stui-content__playlist',
'.myui-content__list', '.module-play-list-content',
'.module-play-list', '.module-list',
'.playlist', '.play-list', '.play_list', '.episodios', '.ff-playurl',
'.play-list-box', '.episode-list', '.episodes-list',
'[class*="anthology"]', '[class*="playlist"]',
'[class*="play-list"]', '[class*="episode-list"]',
'[class*="episodes"]', '[x-data*="episode"]',
'[id*="episode"]', '[id*="playlist"]',
'div[style*="episodes"]'
];
for (var s = 0; s < SELECTORS.length; s++) {
var els = document.querySelectorAll(SELECTORS[s]);
for (var k = 0; k < els.length; k++) addCandidate(els[k]);
}
// 从显式剧集链接直接寻找其父级容器(适配 4kvm 等现代框架动态类名页面)
var epLinkA = document.querySelectorAll('a.episode-link, a[class*="episode-link"], a[data-episode], a[data-nid]');
for (var ea = 0; ea < epLinkA.length; ea++) {
var p = epLinkA[ea].parentElement;
if (p) addCandidate(p);
}
if (candidates.length === 0) {
var allContainers = document.querySelectorAll('ul, ol, div[class*="play"], div[class*="anthology"], div[class*="episode"], div[class*="grid"], div[class*="list"], div[x-data]');
for (var cIdx = 0; cIdx < allContainers.length; cIdx++) {
var c = allContainers[cIdx];
var cLinks = c.querySelectorAll('a[href]');
if (cLinks.length >= 2 && cLinks.length <= 500) {
var epMatchCount = 0;
for (var lIdx = 0; lIdx < Math.min(cLinks.length, 10); lIdx++) {
var aElC = cLinks[lIdx];
var lt = (aElC.getAttribute('data-episode') || aElC.getAttribute('data-num') || aElC.textContent || '').trim();
if (/^第?\s*\d+\s*[集话回期]?$/.test(lt) || /^\d+$/.test(lt)) epMatchCount++;
}
if (epMatchCount >= 2) addCandidate(c);
}
}
}
if (candidates.length === 0) {
var allLinks = document.querySelectorAll('a[href]');
for (var al = 0; al < allLinks.length; al++) {
var aTag = allLinks[al];
var aText = (aTag.getAttribute('data-episode') || aTag.getAttribute('data-num') || aTag.textContent || '').trim();
if (/^第?\s*\d+\s*[集话回期]?$/.test(aText) || /^\d+$/.test(aText)) {
var parentP = aTag.parentElement;
if (parentP && parentP.tagName !== 'BODY' && parentP.tagName !== 'HTML') {
if (parentP.tagName === 'LI' && parentP.parentElement) {
addCandidate(parentP.parentElement);
} else {
addCandidate(parentP);
}
}
}
}
}
// 过滤掉包含其他候选容器的祖先容器,保留最具体的分组容器(避免把多条线路合并为重复单列)
var leafCandidates = candidates.filter(function(c1) {
return !candidates.some(function(c2) {
return c1 !== c2 && c1.contains(c2);
});
});
var effectiveCandidates = leafCandidates.length > 0 ? leafCandidates : candidates;
var domSources = [];
var activeDomIndex = -1;
for (var i = 0; i < effectiveCandidates.length; i++) {
var container = effectiveCandidates[i];
var aTags = container.querySelectorAll('a[href]');
if (aTags.length === 0) continue;
var epList = [];
var hasCur = false;
var seenUrls = {};
for (var j = 0; j < aTags.length; j++) {
var aEl = aTags[j];
var parsedA = parseUrl(aEl.getAttribute('href'));
if (!parsedA || parsedA.host !== cur.host) continue;
var aKey = normKey(parsedA);
var isCurrent = (aKey === curKey);
if (!isCurrent) {
var aNormPath = (parsedA.pathname || '').replace(/\/+$/, '').toLowerCase();
if (aNormPath === curPath && (!parsedA.search || parsedA.search === cur.search)) {
isCurrent = true;
}
}
if (!isCurrent && (
aEl.classList.contains('active') ||
aEl.classList.contains('current') ||
aEl.classList.contains('on') ||
aEl.getAttribute('aria-current') === 'page' ||
aEl.getAttribute('aria-selected') === 'true'
)) {
isCurrent = true;
}
if (isCurrent) hasCur = true;
// 名称优先使用网页上实际显示的文字(去掉角标 / 播放动画等装饰节点)
var clone = aEl.cloneNode(true);
var badNodes = clone.querySelectorAll('.badge, .tag, sup, small, .playing-bars, [class*="badge"], [class*="tag"]');
for (var bz = 0; bz < badNodes.length; bz++) badNodes[bz].remove();
var rawName = (clone.textContent || '').replace(/\s+/g, ' ').trim();
if (!rawName) {
rawName = (aEl.getAttribute('title') ||
aEl.getAttribute('data-title') ||
aEl.getAttribute('data-text') ||
aEl.getAttribute('data-episode') ||
aEl.getAttribute('data-num') ||
aEl.getAttribute('data-index') || '').trim();
}
if (BAD.test(rawName)) continue;
// 同一集的第二个链接(如缩略图 + 标题):之前没有文字时用这次的文字作名称
if (seenUrls[parsedA.href]) {
var prevEp = seenUrls[parsedA.href];
if (prevEp.noName && rawName) { prevEp.name = cleanName(rawName); prevEp.noName = false; }
if (isCurrent) prevEp.isCurrent = true;
continue;
}
var epItem = {
name: cleanName(rawName, epList.length + 1),
url: parsedA.href,
isCurrent: isCurrent,
noName: !rawName
};
seenUrls[parsedA.href] = epItem;
epList.push(epItem);
}
if (epList.length >= 1) {
domSources.push({
element: container,
episodes: epList,
hasCurrent: hasCur
});
if (hasCur && activeDomIndex < 0) {
activeDomIndex = domSources.length - 1;
}
}
}
// -------------------------------------------------------------
// 策略 2: 厂长 / 4kcz Base64 路由专用匹配 (/v_play/bXZf... )
// -------------------------------------------------------------
var b64Episodes = [];
var b64Match = cur.pathname.match(/\/v_play\/([A-Za-z0-9+/=]+)/);
if (b64Match) {
var curDec = tryDecodeB64(b64Match[1]);
if (curDec) {
var prefixMatch = curDec.match(/(.+?)[_-]nm[_-](\d+)/);
var targetPrefix = prefixMatch ? prefixMatch[1] : curDec;
var allPageLinks = document.querySelectorAll('a[href*="/v_play/"]');
var b64Map = {};
for (var bp = 0; bp < allPageLinks.length; bp++) {
var bHref = allPageLinks[bp].getAttribute('href');
var bSubMatch = (bHref || '').match(/\/v_play\/([A-Za-z0-9+/=]+)/);
if (!bSubMatch) continue;
var bDec = tryDecodeB64(bSubMatch[1]);
if (!bDec) continue;
var bParts = bDec.match(/(.+?)[_-]nm[_-](\d+)/);
if (!bParts || bParts[1] !== targetPrefix) continue;
var bNid = parseInt(bParts[2], 10);
var bFull = parseUrl(bHref);
if (!bFull) continue;
var bTxt = (allPageLinks[bp].textContent || allPageLinks[bp].getAttribute('title') || '').trim();
if (BAD.test(bTxt)) continue;
if (!b64Map[bNid]) {
b64Map[bNid] = {
nid: bNid,
name: cleanName(bTxt, bNid),
url: bFull.href
};
}
}
var bKeys = Object.keys(b64Map).map(function(k) { return parseInt(k, 10); }).sort(function(a, b) { return a - b; });
b64Episodes = bKeys.map(function(k) { return b64Map[k]; });
}
}
// -------------------------------------------------------------
// 策略 3: MacCMS 经典纯数字 URL 骨架推导
// -------------------------------------------------------------
var curNums = cur.pathname.match(/\d+/g) || [];
var curSkel = cur.pathname.replace(/\d+/g, '#');
var maccmsEpisodes = [];
var maccmsSources = [];
var maccmsSourceIndex = -1;
if (curNums.length > 0) {
var map = {}, order = [];
var as = document.querySelectorAll('a[href]');
for (var mIdx = 0; mIdx < as.length; mIdx++) {
var aMac = parseUrl(as[mIdx].getAttribute('href'));
if (!aMac || aMac.host !== cur.host) continue;
if (aMac.pathname.replace(/\d+/g, '#') !== curSkel) continue;
var n = aMac.pathname.match(/\d+/g) || [];
if (n.length !== curNums.length) continue;
var same = true;
for (var k = 0; k < n.length - 1; k++) { if (n[k] !== curNums[k]) { same = false; break; } }
if (!same) continue;
var key = normKey(aMac);
var name = (as[mIdx].textContent || as[mIdx].getAttribute('title') || '').replace(/[\uE000-\uF8FF]/g, '').replace(/\s+/g, ' ').trim();
if (BAD.test(name)) continue;
var e = map[key];
if (!e) { e = map[key] = { url: aMac.href, nid: parseInt(n[n.length - 1], 10), name: '' }; order.push(key); }
if (name && (!e.name || BAD.test(e.name))) e.name = name;
}
var eps = order.map(function(k) { return map[k]; });
eps.sort(function(x, y) { return x.nid - y.nid; });
maccmsEpisodes = eps.map(function(e) { return { name: e.name || ('第' + e.nid + '集'), url: e.url }; });
if (curNums.length >= 2) {
var groups = {}, gorder = [];
for (var m2 = 0; m2 < as.length; m2++) {
var b2 = parseUrl(as[m2].getAttribute('href'));
if (!b2 || b2.host !== cur.host) continue;
if (b2.pathname.replace(/\d+/g, '#') !== curSkel) continue;
var mNums = b2.pathname.match(/\d+/g) || [];
if (mNums.length !== curNums.length) continue;
var ok = true;
for (var k2 = 0; k2 < mNums.length - 2; k2++) { if (mNums[k2] !== curNums[k2]) { ok = false; break; } }
if (!ok) continue;
var sid = mNums[mNums.length - 2];
var g = groups[sid];
if (!g) { g = groups[sid] = { sid: sid, map: {}, order: [] }; gorder.push(sid); }
var key2 = normKey(b2);
var nm = (as[m2].textContent || as[m2].getAttribute('title') || '').replace(/[\uE000-\uF8FF]/g, '').replace(/\s+/g, ' ').trim();
if (BAD.test(nm)) continue;
var ge = g.map[key2];
if (!ge) { ge = g.map[key2] = { url: b2.href, nid: parseInt(mNums[mNums.length - 1], 10), name: '' }; g.order.push(key2); }
if (nm && (!ge.name || BAD.test(ge.name))) ge.name = nm;
}
if (gorder.length > 1) {
for (var s2 = 0; s2 < gorder.length; s2++) {
var gg = groups[gorder[s2]];
var ges = gg.order.map(function(k) { return gg.map[k]; });
ges.sort(function(x, y) { return x.nid - y.nid; });
maccmsSources.push({
name: '线路' + (s2 + 1),
episodes: ges.map(function(e) { return { name: e.name || ('第' + e.nid + '集'), url: e.url }; })
});
if (gorder[s2] === curNums[curNums.length - 2]) maccmsSourceIndex = s2;
}
}
}
}
// -------------------------------------------------------------
// 策略 4: 路径前缀同构嗅探(针对 /play/xxx、/v/xxx 等非纯数字路由兜底)
// -------------------------------------------------------------
var pathEpisodes = [];
if (domSources.length === 0 && b64Episodes.length === 0 && maccmsEpisodes.length === 0) {
var lastSlash = cur.pathname.lastIndexOf('/');
if (lastSlash > 0) {
var dirPrefix = cur.pathname.substring(0, lastSlash + 1).toLowerCase();
var allPageA = document.querySelectorAll('a[href]');
var pMap = {}, pOrder = [];
for (var pi = 0; pi < allPageA.length; pi++) {
var pEl = allPageA[pi];
var pParsed = parseUrl(pEl.getAttribute('href'));
if (!pParsed || pParsed.host !== cur.host) continue;
var pPath = pParsed.pathname.toLowerCase();
if (pPath.indexOf(dirPrefix) !== 0) continue;
var slug = pParsed.pathname.substring(dirPrefix.length).replace(/\/+$/, '');
if (!slug || slug.indexOf('/') >= 0) continue;
var pText = ((pEl.textContent || '').replace(/\s+/g, ' ').trim() ||
pEl.getAttribute('title') ||
pEl.getAttribute('data-episode') ||
pEl.getAttribute('data-num') ||
pEl.getAttribute('data-index') || '').trim();
if (BAD.test(pText)) continue;
if (!pEl.getAttribute('data-episode') && !/^第?\s*\d+\s*[集话回期]?$/.test(pText) && !/^\d+$/.test(pText)) continue;
var pKey = normKey(pParsed);
if (!pMap[pKey]) {
pMap[pKey] = {
url: pParsed.href,
name: cleanName(pText, pOrder.length + 1),
isCurrent: (pKey === curKey)
};
pOrder.push(pKey);
}
}
if (pOrder.length >= 2) {
pathEpisodes = pOrder.map(function(k) { return pMap[k]; });
}
}
}
// -------------------------------------------------------------
// 综合决策:选择最佳选集与线路数据
// -------------------------------------------------------------
var finalEpisodes = [];
var finalSources = [];
var finalSourceIndex = -1;
var TAB_SEL = [
'.anthology-tab a', '.play-source a', '.play-source-tab a',
'.module-tab-item', '.vod-playerUrl',
'.stui-pannel__head .nav-tabs li a', '.myui-panel__head .nav-tabs li a',
'.playlist-tab li', '.nav-tabs li a', '.tab-item',
'.play_source_tab a', '.source-tab a', '[class*="tab"] a'
];
function getTabNames(count) {
for (var t = 0; t < TAB_SEL.length; t++) {
var tabs = document.querySelectorAll(TAB_SEL[t]);
if (tabs.length === count) {
var names = [];
for (var q = 0; q < tabs.length; q++) {
var c = tabs[q].cloneNode(true);
var bad = c.querySelectorAll('.badge, .tag, sup, small, .count');
for (var z = 0; z < bad.length; z++) bad[z].remove();
names.push((c.textContent || '').replace(/[\uE000-\uF8FF]/g, '').replace(/\s+/g, ' ').trim());
}
return names;
}
}
return null;
}
if (domSources.length > 0) {
var chosen = (activeDomIndex >= 0) ? domSources[activeDomIndex] : domSources[0];
finalEpisodes = chosen.episodes;
if (domSources.length > 1) {
var tabNames = getTabNames(domSources.length);
for (var ds = 0; ds < domSources.length; ds++) {
finalSources.push({
name: (tabNames && tabNames[ds]) || ('线路' + (ds + 1)),
episodes: domSources[ds].episodes.map(function(e) { return { name: e.name, url: e.url }; })
});
}
finalSourceIndex = (activeDomIndex >= 0) ? activeDomIndex : 0;
}
} else if (b64Episodes.length > 0) {
finalEpisodes = b64Episodes;
} else if (maccmsEpisodes.length > 0) {
finalEpisodes = maccmsEpisodes;
if (maccmsSources.length > 1) {
var tNames = getTabNames(maccmsSources.length);
if (tNames) {
for (var ms = 0; ms < maccmsSources.length; ms++) {
maccmsSources[ms].name = tNames[ms] || maccmsSources[ms].name;
}
}
finalSources = maccmsSources;
finalSourceIndex = maccmsSourceIndex;
}
} else if (pathEpisodes.length > 0) {
finalEpisodes = pathEpisodes;
}
if (b64Episodes.length > finalEpisodes.length) {
finalEpisodes = b64Episodes;
}
// -------------------------------------------------------------
// 标题与剧集名清洗
// -------------------------------------------------------------
var seriesTitle = '';
var episodeName = '';
if (pd) {
var vd = pd.vod_data || {};
seriesTitle = vd.vod_name || pd.vod_title || '';
episodeName = pd.vod_title_name || '';
}
if (!seriesTitle) {
var h1 = document.querySelector('h1.title, h1.video-title, h1.anthology-title, .caption h1, h1');
if (h1) seriesTitle = (h1.textContent || '').trim();
}
if (!seriesTitle) {
var og = document.querySelector('meta[property="og:title"]');
if (og) seriesTitle = (og.getAttribute('content') || '').trim();
}
if (!seriesTitle) {
seriesTitle = (document.title || '').trim();
}
var bookMatch = seriesTitle.match(/《([^》]+)》/);
if (bookMatch) {
seriesTitle = bookMatch[1].trim();
} else {
seriesTitle = seriesTitle.replace(/[-_\|][\s\S]*$/, '').trim();
seriesTitle = seriesTitle.replace(/第?\s*\d+\s*[集话回期]?/g, '').replace(/在线[观播]看/g, '').replace(/高清[完整版]*$/g, '').trim();
}
seriesTitle = seriesTitle.replace(/(-4k影视|-厂长资源|-极速影院|-电影网|-在线播放)$/i, '').trim();
if (!episodeName && finalEpisodes.length > 0) {
for (var fe = 0; fe < finalEpisodes.length; fe++) {
if (finalEpisodes[fe].isCurrent) {
episodeName = finalEpisodes[fe].name;
break;
}
}
}
if (!episodeName) {
var activeEl = document.querySelector('.anthology-list .active, .playlist .active, a.active, a.current, [aria-current="page"], [aria-selected="true"]');
if (activeEl) {
var aRaw = ((activeEl.textContent || '').replace(/\s+/g, ' ').trim() || activeEl.getAttribute('data-episode') || activeEl.getAttribute('data-num') || '').trim();
episodeName = cleanName(aRaw);
} else {
var epTitleMatch = document.title.match(/第\s*(\d+)\s*[集话回期]/);
if (epTitleMatch) {
var epN = parseInt(epTitleMatch[1], 10);
episodeName = '第' + (epN < 10 ? '0' + epN : epN) + '集';
}
}
}
// 下一集推导
var nextUrl = '';
if (pd && pd.link_next) {
var pNext = parseUrl(pd.link_next);
if (pNext) nextUrl = pNext.href;
}
if (!nextUrl && finalEpisodes.length > 0) {
var cIdx = -1;
for (var f = 0; f < finalEpisodes.length; f++) {
if (finalEpisodes[f].isCurrent) {
cIdx = f;
break;
}
var fUrl = parseUrl(finalEpisodes[f].url);
if (fUrl && (normKey(fUrl) === curKey || (!fUrl.search && !cur.search && (fUrl.pathname || '').replace(/\/+$/, '').toLowerCase() === curPath))) {
cIdx = f;
break;
}
}
if (cIdx >= 0 && cIdx + 1 < finalEpisodes.length) {
nextUrl = finalEpisodes[cIdx + 1].url;
}
}
var adapterTag = pd ? 'maccms' : (b64Episodes.length > 0 ? '4kcz' : (domSources.length > 0 ? (cur.host.indexOf('4kvm') >= 0 ? '4kvm' : 'dom') : 'path'));
var info = {
adapter: adapterTag,
title: seriesTitle,
episodeName: episodeName,
current: cur.href,
next: nextUrl,
episodes: finalEpisodes.map(function(e) { return { name: e.name, url: e.url }; }),
sources: finalSources,
sourceIndex: finalSourceIndex
};
if (info.episodes.length > 0) {
window.AndroidVideoBridge.onSiteInfo(JSON.stringify(info));
}
} catch (e) {}
})();
""".trimIndent()
/**
* 网页 DOM 视频嗅探 JavaScript:
* 1. 递归查找主文档与各 <iframe> 内的 video 标签真实源地址
* 2. 解析 application/ld+json 中的 contentUrl
* 3. 找到后通过 AndroidVideoBridge 接口通知原生层
*/
val JS_SNIFF_VIDEO = """
(function() {
try {
function reportVideo(src) {
if (!src || typeof src !== 'string') return;
src = src.trim();
if (src.indexOf('http://') === 0 || src.indexOf('https://') === 0) {
if (window.AndroidVideoBridge) {
window.AndroidVideoBridge.onVideoFound(src, document.title || '');
}
}
}
function findVideos(doc) {
if (!doc) return;
var videos = doc.querySelectorAll('video');
for (var i = 0; i < videos.length; i++) {
var v = videos[i];
var s = v.currentSrc || v.src;
if (s) reportVideo(s);
var sources = v.querySelectorAll('source');
for (var j = 0; j < sources.length; j++) {
var ss = sources[j].src;
if (ss) reportVideo(ss);
}
}
var iframes = doc.querySelectorAll('iframe');
for (var k = 0; k < iframes.length; k++) {
try {
findVideos(iframes[k].contentDocument);
} catch(e) {}
}
}
findVideos(document);
// 监听后续动态添加或加载的视频
try {
new MutationObserver(function() {
findVideos(document);
}).observe(document.documentElement || document.body, { childList: true, subtree: true });
} catch(e) {}
} catch(e) {}
})();
""".trimIndent()
}
@@ -0,0 +1,143 @@
package com.tv.browser
import android.os.Bundle
import org.json.JSONArray
import org.json.JSONObject
/**
* 可播放的视频源:除地址外还带上「来源页」和「请求头」。
* 很多影视站的 m3u8 会校验 Referer / UA / Cookie,原生播放器必须原样带上,否则会 403。
*/
data class VideoSource(
val url: String,
val title: String = "",
/** 视频所在的网页(剧集播放页),用于断点续播 key、重新解析、选集定位 */
val pageUrl: String? = null,
/** 播放时要带的请求头(User-Agent / Referer / Cookie 等) */
val headers: Map<String, String> = emptyMap(),
/** 是否确定为 HLS(有些 m3u8 地址没有 .m3u8 后缀,需要显式告诉播放器) */
val isHls: Boolean = url.contains(".m3u8", ignoreCase = true)
) {
fun headersBundle(): Bundle = Bundle().apply { headers.forEach { (k, v) -> putString(k, v) } }
companion object {
fun headersFromBundle(bundle: Bundle?): Map<String, String> {
if (bundle == null) return emptyMap()
val map = LinkedHashMap<String, String>()
for (key in bundle.keySet()) {
bundle.getString(key)?.let { map[key] = it }
}
return map
}
}
}
/** 剧集 */
data class Episode(val name: String, val url: String)
/** 播放线路(如「NB源」「量子资源」),每条线路有自己的选集列表 */
data class PlaySource(val name: String, val episodes: List<Episode>) {
companion object {
/** 线路列表与 JSON 互转(用于 Intent 传递) */
fun listToJson(list: List<PlaySource>): String {
val arr = JSONArray()
list.forEach { s ->
arr.put(JSONObject().apply {
put("name", s.name)
put("episodes", episodesToJson(s.episodes))
})
}
return arr.toString()
}
fun listFromJson(json: String?): List<PlaySource> {
if (json.isNullOrBlank()) return emptyList()
return try { parseArray(JSONArray(json)) } catch (_: Exception) { emptyList() }
}
internal fun parseArray(arr: JSONArray?): List<PlaySource> {
if (arr == null) return emptyList()
val out = mutableListOf<PlaySource>()
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
val eps = parseEpisodes(o.optJSONArray("episodes"))
if (eps.isNotEmpty()) out.add(PlaySource(o.optString("name").ifBlank { "线路${i + 1}" }, eps))
}
return out
}
internal fun parseEpisodes(arr: JSONArray?): List<Episode> {
val eps = mutableListOf<Episode>()
if (arr == null) return eps
for (i in 0 until arr.length()) {
val e = arr.optJSONObject(i) ?: continue
val url = e.optString("url")
if (url.isNotBlank()) eps.add(Episode(e.optString("name").ifBlank { "第${i + 1}集" }, url))
}
return eps
}
private fun episodesToJson(list: List<Episode>): JSONArray {
val arr = JSONArray()
list.forEach { arr.put(JSONObject().put("name", it.name).put("url", it.url)) }
return arr
}
}
}
/**
* 站点适配器从网页中提取出的结构化信息(片名、当前集、选集列表、下一集、播放线路)
*/
data class SiteInfo(
val adapter: String,
val title: String,
val episodeName: String,
val current: String,
val next: String,
val episodes: List<Episode>,
/** 全部播放线路(只有一条线路时为空) */
val sources: List<PlaySource> = emptyList(),
/** 当前页属于第几条线路,-1 表示未知 */
val sourceIndex: Int = -1
) {
/** 当前播放页在选集列表中的下标,找不到返回 -1 */
fun currentIndex(pageUrl: String? = current): Int {
val key = normalizeUrl(pageUrl ?: return -1)
return episodes.indexOfFirst { normalizeUrl(it.url) == key }
}
/** 展示用标题:片名 + 集名,如「沧元图 第01集」 */
fun displayTitle(): String {
val ep = episodeName.trim()
if (ep.isEmpty() || title.contains(ep)) return title
val epText = if (ep.all { it.isDigit() }) "第${ep.padStart(2, '0')}集" else ep
return "$title $epText"
}
companion object {
fun fromJson(json: String?): SiteInfo? {
if (json.isNullOrBlank()) return null
return try {
val o = JSONObject(json)
val eps = PlaySource.parseEpisodes(o.optJSONArray("episodes"))
val sources = PlaySource.parseArray(o.optJSONArray("sources"))
SiteInfo(
adapter = o.optString("adapter"),
title = o.optString("title"),
episodeName = o.optString("episodeName"),
current = o.optString("current"),
next = o.optString("next"),
episodes = eps,
sources = if (sources.size > 1) sources else emptyList(),
sourceIndex = if (sources.size > 1) o.optInt("sourceIndex", -1) else -1
)
} catch (_: Exception) {
null
}
}
/** 去掉 #hash 与末尾斜杠,用于比较两个页面地址是否为同一集 */
fun normalizeUrl(url: String): String =
url.substringBefore('#').trimEnd('/').lowercase()
}
}
@@ -0,0 +1,73 @@
package com.tv.browser
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.webkit.WebSettings
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
import com.tv.browser.databinding.ActivityVideoWebBinding
/**
* 新页面播放:用 WebView 加载仅含 &lt;video src="url"&gt; 的简单页,用于直链或 contentUrl。
*/
class VideoWebActivity : AppCompatActivity() {
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = ActivityVideoWebBinding.inflate(layoutInflater)
setContentView(binding.root)
val url = intent.getStringExtra(EXTRA_VIDEO_URL) ?: ""
if (url.isBlank()) {
finish()
return
}
binding.webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
useWideViewPort = true
loadWithOverviewMode = true
}
binding.webView.webViewClient = object : android.webkit.WebViewClient() {
override fun onReceivedSslError(
view: WebView?,
handler: android.webkit.SslErrorHandler?,
error: android.net.http.SslError?
) {
handler?.proceed()
}
}
val html = """
<!DOCTYPE html>
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;background:#000;">
<video src="${escapeHtmlAttr(url)}" controls autoplay playsinline style="width:100%;height:100%;object-fit:contain;"></video>
</body></html>
""".trimIndent()
binding.webView.loadDataWithBaseURL(
intent.getStringExtra(EXTRA_BASE_URL).orEmpty(),
html,
"text/html",
"UTF-8",
null
)
}
private fun escapeHtmlAttr(s: String): String {
return s.replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
}
companion object {
const val EXTRA_VIDEO_URL = "video_url"
const val EXTRA_BASE_URL = "base_url"
}
}
@@ -0,0 +1,226 @@
package com.tv.browser
import android.content.Context
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.webkit.CookieManager
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
/** 一条聚合搜索结果 */
data class VodResult(
val name: String,
val siteName: String,
val host: String,
val detailUrl: String
)
/**
* 跨站聚合搜片:在用户收藏的影视站里同时搜索同一个片名。
*
* 绝大多数盗版影视站都基于苹果 CMS(MacCMS),它的搜索页通常要验证码,
* 但联想接口 `/index.php/ajax/suggest?mid=1&wd=关键词` 不需要验证码,返回 JSON:
* `{code:1, list:[{id, name, en, pic}]}`。拿到 id 后再拼出详情页地址。
*
* 详情页地址各站伪静态规则不同(/voddetail/123.html、/detail/123.html …),
* 首次搜索某站时抓一次首页,找出一个详情页链接推断模板并缓存;找不到时用 MacCMS 动态路由。
* 非苹果 CMS 站点接口不存在 / 返回非 JSON,直接忽略。
*/
class VodSearchHelper(context: Context) {
interface Listener {
/** 每个站点搜完回调一次(主线程),[results] 为该站结果 */
fun onSiteDone(site: String, results: List<VodResult>, done: Int, total: Int)
}
private val appContext = context.applicationContext
private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val mainHandler = Handler(Looper.getMainLooper())
private var executor: ExecutorService? = null
@Volatile private var generation = 0
/** 收藏里去重后的站点(按域名),返回 (origin, 站点名) */
fun collectSites(bookmarks: List<Bookmark>): List<Pair<String, String>> {
val seen = LinkedHashMap<String, Pair<String, String>>()
for (b in bookmarks) {
val uri = runCatching { Uri.parse(b.url.trim()) }.getOrNull() ?: continue
val scheme = uri.scheme?.lowercase() ?: continue
if (scheme != "http" && scheme != "https") continue
val host = uri.host?.lowercase() ?: continue
if (host in SKIP_HOSTS || SKIP_HOSTS.any { host.endsWith(".$it") }) continue
val origin = "$scheme://${uri.authority}"
if (!seen.containsKey(host)) seen[host] = origin to b.title.ifBlank { host }
}
return seen.values.toList()
}
fun search(keyword: String, sites: List<Pair<String, String>>, userAgent: String, listener: Listener) {
cancel()
val gen = ++generation
val pool = Executors.newFixedThreadPool(THREADS).also { executor = it }
val total = sites.size
var done = 0
val cookies = CookieManager.getInstance()
for ((origin, siteName) in sites) {
val cookie = runCatching { cookies.getCookie(origin) }.getOrNull()
pool.execute {
val results = runCatching { searchSite(origin, siteName, keyword, userAgent, cookie) }
.onFailure { Log.d(TAG, "$origin failed: ${it.message}") }
.getOrDefault(emptyList())
mainHandler.post {
if (gen != generation) return@post
done++
listener.onSiteDone(siteName, results, done, total)
}
}
}
pool.shutdown()
}
fun cancel() {
generation++
executor?.shutdownNow()
executor = null
}
private fun searchSite(
origin: String, siteName: String, keyword: String, ua: String, cookie: String?
): List<VodResult> {
val kw = URLEncoder.encode(keyword, "UTF-8")
val body = httpGet("$origin/index.php/ajax/suggest?mid=1&wd=$kw&limit=30", ua, cookie, origin)
?: return emptyList()
val json = runCatching { JSONObject(body.trim()) }.getOrNull() ?: return emptyList()
val list = json.optJSONArray("list") ?: return emptyList()
if (list.length() == 0) return emptyList()
val host = Uri.parse(origin).host.orEmpty()
val sample = list.optJSONObject(0)
val template = detailTemplate(
origin, host, ua, cookie,
sample?.optString("id").orEmpty(), sample?.optString("name").orEmpty().trim()
)
val out = ArrayList<VodResult>()
for (i in 0 until list.length()) {
val o = list.optJSONObject(i) ?: continue
val id = o.optString("id").ifBlank { o.optString("vod_id") }
val name = o.optString("name").ifBlank { o.optString("vod_name") }.trim()
if (id.isBlank() || name.isBlank()) continue
val path = template.replace("{id}", id)
out.add(VodResult(name, siteName, host, if (path.startsWith("http")) path else origin + path))
}
Log.d(TAG, "$origin -> ${out.size} results (tpl=$template)")
return out
}
/**
* 推断详情页地址模板,如 `/voddetail/{id}.html`;结果按域名缓存。
* 候选:首页里找到的本站详情链接 + 常见伪静态规则,逐个用 [sampleId] 实际请求验证
* (返回 2xx 且页面里出现片名 [sampleName] 才算对)。
*/
private fun detailTemplate(
origin: String, host: String, ua: String, cookie: String?, sampleId: String, sampleName: String
): String {
prefs.getString("tpl_$host", null)?.let { return it }
val candidates = LinkedHashSet<String>()
val html = runCatching { httpGet("$origin/", ua, cookie, origin, ajax = false) }.getOrNull()
if (html != null) {
// 第一个匹配可能是广告外链,逐个找本站链接
for (m in DETAIL_HREF.findAll(html)) {
var href = m.groupValues[1]
if (href.startsWith("//")) href = Uri.parse(origin).scheme + ":" + href
if (href.startsWith("http")) {
val u = runCatching { Uri.parse(href) }.getOrNull() ?: continue
if (u.host != host) continue
href = u.path.orEmpty()
}
if (!href.startsWith("/")) continue
val last = Regex("\\d+").findAll(href).lastOrNull() ?: continue
candidates.add(href.replaceRange(last.range, "{id}"))
if (candidates.size >= 2) break
}
}
candidates.addAll(COMMON_TEMPLATES)
Log.d(TAG, "$host home=${html?.length ?: -1} candidates=$candidates")
for (tpl in candidates) {
val url = origin + tpl.replace("{id}", sampleId)
val page = runCatching { httpGet(url, ua, cookie, origin, ajax = false) }.getOrNull() ?: continue
if (if (sampleName.isNotEmpty()) page.contains(sampleName) else page.length > 2000) {
prefs.edit().putString("tpl_$host", tpl).apply()
Log.d(TAG, "$host template verified: $tpl")
return tpl
}
}
// 都没验证通过(可能是网络问题),不缓存,下次再试
return candidates.first()
}
private fun httpGet(url: String, ua: String, cookie: String?, referer: String, ajax: Boolean = true): String? {
val conn = URL(url).openConnection() as HttpURLConnection
try {
conn.connectTimeout = TIMEOUT_MS
conn.readTimeout = TIMEOUT_MS
conn.instanceFollowRedirects = true
conn.setRequestProperty("User-Agent", ua)
conn.setRequestProperty("Referer", "$referer/")
if (ajax) conn.setRequestProperty("X-Requested-With", "XMLHttpRequest")
conn.setRequestProperty("Accept", if (ajax) "application/json, text/javascript, */*" else "text/html,*/*")
if (!cookie.isNullOrBlank()) conn.setRequestProperty("Cookie", cookie)
if (conn.responseCode !in 200..299) return null
val bytes = conn.inputStream.use { input ->
// 首页可能很大,最多读 1MB
val buf = java.io.ByteArrayOutputStream()
val chunk = ByteArray(16 * 1024)
while (buf.size() < MAX_BYTES) {
val n = input.read(chunk)
if (n < 0) break
buf.write(chunk, 0, n)
}
buf.toByteArray()
}
return String(bytes, Charsets.UTF_8)
} finally {
conn.disconnect()
}
}
companion object {
private const val TAG = "TVBrowser.VodSearch"
private const val PREFS_NAME = "tv_browser_vod_search"
private const val THREADS = 4
private const val TIMEOUT_MS = 6000
private const val MAX_BYTES = 1024 * 1024
/** 常见的苹果 CMS 详情页伪静态规则(最后一个是动态路由,伪静态未开时也能用) */
private val COMMON_TEMPLATES = listOf(
"/voddetail/{id}.html",
"/vod/detail/id/{id}.html",
"/detail/{id}.html",
"/index.php/vod/detail/id/{id}.html"
)
private val DETAIL_HREF =
Regex("href=[\"']([^\"'\\s]*?detail[^\"'\\s]*?\\d+[^\"'\\s]*?)[\"']", RegexOption.IGNORE_CASE)
/** 明显不是苹果 CMS 的大站,不浪费请求 */
private val SKIP_HOSTS = setOf(
"baidu.com", "bing.com", "google.com", "sogou.com", "so.com", "bilibili.com",
"iqiyi.com", "youku.com", "qq.com", "mgtv.com", "douban.com", "youtube.com",
"github.com", "zhihu.com", "weibo.com", "sohu.com", "163.com", "cctv.com", "le.com"
)
/** 结果排序:完全同名 > 以关键词开头 > 包含关键词 > 其它 */
fun score(name: String, keyword: String): Int {
val n = name.replace(" ", "").lowercase()
val k = keyword.replace(" ", "").lowercase()
return when {
n == k -> 0
n.startsWith(k) -> 1
n.contains(k) -> 2
else -> 3
}
}
}
}