init
This commit is contained in:
@@ -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 加载仅含 <video src="url"> 的简单页,用于直链或 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("&", "&")
|
||||
.replace("\"", """)
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user