This commit is contained in:
haomingming
2026-11-09 11:24:40 +08:00
parent 813771abd2
commit 227a39552d
18 changed files with 2405 additions and 2062 deletions
+2 -1
View File
@@ -17,7 +17,8 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = false isMinifyEnabled = true
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
+15
View File
@@ -1,6 +1,17 @@
# Add project specific ProGuard rules here. # Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the # You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle. # proguardFiles setting in build.gradle.
# Keep attributes required for WebView JavascriptInterface & reflection
-keepattributes JavascriptInterface
-keepattributes *Annotation*
-keepattributes Signature, InnerClasses, EnclosingMethod
# Keep all @JavascriptInterface methods across the app
-keepclassmembers class com.tv.browser.** {
@android.webkit.JavascriptInterface <methods>;
}
# Keep WebView related classes # Keep WebView related classes
-keepclassmembers class * extends android.webkit.WebViewClient { -keepclassmembers class * extends android.webkit.WebViewClient {
public void *(android.webkit.WebView, java.lang.String); public void *(android.webkit.WebView, java.lang.String);
@@ -9,3 +20,7 @@
-keepclassmembers class * extends android.webkit.WebChromeClient { -keepclassmembers class * extends android.webkit.WebChromeClient {
public void *(android.webkit.WebView, java.lang.String); public void *(android.webkit.WebView, java.lang.String);
} }
# NanoHTTPD
-dontwarn fi.iki.elonen.**
-keep class fi.iki.elonen.** { *; }
@@ -2,7 +2,6 @@ package com.tv.browser
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log
import android.webkit.WebResourceResponse import android.webkit.WebResourceResponse
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@@ -15,9 +14,10 @@ import java.io.ByteArrayInputStream
*/ */
object AdBlockHelper { object AdBlockHelper {
private const val TAG = "TVBrowser.AdBlock"
private const val PREFS_NAME = "tv_browser_adblock" private const val PREFS_NAME = "tv_browser_adblock"
private const val KEY_ADBLOCK_ENABLED = "adblock_enabled" private const val KEY_ADBLOCK_ENABLED = "adblock_enabled"
@Volatile
private var cachedEnabled: Boolean? = null
/** 常见广告与跟踪域名黑名单关键词 */ /** 常见广告与跟踪域名黑名单关键词 */
private val AD_HOST_PATTERNS = arrayOf( private val AD_HOST_PATTERNS = arrayOf(
@@ -62,14 +62,16 @@ object AdBlockHelper {
"intent" "intent"
) )
/** 是否开启去广告(默认开启) */ /** 是否开启去广告(默认开启,内存缓存免除 shouldInterceptRequest 高频读取 SharedPreferences) */
fun isEnabled(context: Context): Boolean { fun isEnabled(context: Context): Boolean {
cachedEnabled?.let { return it }
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(KEY_ADBLOCK_ENABLED, true) return prefs.getBoolean(KEY_ADBLOCK_ENABLED, true).also { cachedEnabled = it }
} }
/** 设置去广告开关 */ /** 设置去广告开关 */
fun setEnabled(context: Context, enabled: Boolean) { fun setEnabled(context: Context, enabled: Boolean) {
cachedEnabled = enabled
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit().putBoolean(KEY_ADBLOCK_ENABLED, enabled).apply() prefs.edit().putBoolean(KEY_ADBLOCK_ENABLED, enabled).apply()
} }
@@ -107,7 +109,7 @@ object AdBlockHelper {
* 强力元素隐藏与弹窗防劫持注入脚本: * 强力元素隐藏与弹窗防劫持注入脚本:
* 1. 隐藏高层级遮罩、悬浮关闭广告、通用横幅容器 * 1. 隐藏高层级遮罩、悬浮关闭广告、通用横幅容器
* 2. 覆盖 window.open,防止影视网站静默弹窗跳出 * 2. 覆盖 window.open,防止影视网站静默弹窗跳出
* 3. MutationObserver 监听动态插入的广告节点 * 3. MutationObserver 监听动态插入的广告节点(带 250ms 节流防抖,避免高频 DOM 变动导致低配电视重排卡顿)
*/ */
val JS_INJECT_ADBLOCK = """ val JS_INJECT_ADBLOCK = """
(function() { (function() {
@@ -138,20 +140,30 @@ object AdBlockHelper {
style.appendChild(document.createTextNode(css)); style.appendChild(document.createTextNode(css));
(document.head || document.documentElement).appendChild(style); (document.head || document.documentElement).appendChild(style);
// 3. 动态监听并隐藏常见弹层遮罩 // 3. 动态监听并隐藏常见弹层遮罩(节流执行)
function cleanPopups() { function cleanPopups() {
var popups = document.querySelectorAll('div[style*="z-index"], iframe[src*="ad"]'); var popups = document.querySelectorAll('div[style*="z-index"], iframe[src*="ad"]');
for (var i = 0; i < popups.length; i++) { for (var i = 0; i < popups.length; i++) {
var el = popups[i]; var el = popups[i];
if (el.__tvAdCleaned) continue;
var z = parseInt(window.getComputedStyle(el).zIndex) || 0; var z = parseInt(window.getComputedStyle(el).zIndex) || 0;
if (z >= 99999 && !el.querySelector('video')) { if (z >= 99999 && !el.querySelector('video')) {
el.__tvAdCleaned = true;
el.style.setProperty('display', 'none', 'important'); el.style.setProperty('display', 'none', 'important');
} }
} }
} }
var cleanTimer = null;
function scheduleClean() {
if (cleanTimer) return;
cleanTimer = setTimeout(function() {
cleanTimer = null;
cleanPopups();
}, 250);
}
cleanPopups(); cleanPopups();
try { try {
new MutationObserver(cleanPopups).observe(document.documentElement || document.body, { childList: true, subtree: true }); new MutationObserver(scheduleClean).observe(document.documentElement || document.body, { childList: true, subtree: true });
} catch(e) {} } catch(e) {}
} catch(e) {} } catch(e) {}
})(); })();
@@ -5,6 +5,7 @@ import android.content.Context
/** /**
* 应用级小设置: * 应用级小设置:
* - 自动接管播放(嗅探到视频后倒计时自动进入原生播放器) * - 自动接管播放(嗅探到视频后倒计时自动进入原生播放器)
* - 播放倍速记忆
* - 按剧记住的片头 / 片尾时长(跳过片头片尾) * - 按剧记住的片头 / 片尾时长(跳过片头片尾)
*/ */
object AppSettings { object AppSettings {
@@ -13,6 +14,8 @@ object AppSettings {
private const val KEY_DECODER_MODE = "decoder_mode" private const val KEY_DECODER_MODE = "decoder_mode"
private const val KEY_PRELOAD_NEXT = "preload_next" private const val KEY_PRELOAD_NEXT = "preload_next"
private const val KEY_PURIFY_PAGE = "purify_page" private const val KEY_PURIFY_PAGE = "purify_page"
private const val KEY_PLAYBACK_SPEED = "playback_speed"
private const val KEY_LENIENT_SSL = "lenient_ssl"
private const val SKIP_PREFIX = "skip|" private const val SKIP_PREFIX = "skip|"
/** 解码兼容模式 */ /** 解码兼容模式 */
@@ -53,6 +56,22 @@ object AppSettings {
prefs(context).edit().putBoolean(KEY_PURIFY_PAGE, enabled).apply() prefs(context).edit().putBoolean(KEY_PURIFY_PAGE, enabled).apply()
} }
/** 记忆的播放倍速(默认 1.0x) */
fun getPlaybackSpeed(context: Context): Float =
prefs(context).getFloat(KEY_PLAYBACK_SPEED, 1.0f).coerceIn(0.5f, 3.0f)
fun setPlaybackSpeed(context: Context, speed: Float) {
prefs(context).edit().putFloat(KEY_PLAYBACK_SPEED, speed.coerceIn(0.5f, 3.0f)).apply()
}
/** 宽松 SSL 兼容模式(默认开启,兼容电视系统时间未同步与老旧根证书;关闭后仅对日期异常放行,严格拦截假证书) */
fun isLenientSsl(context: Context): Boolean =
prefs(context).getBoolean(KEY_LENIENT_SSL, true)
fun setLenientSsl(context: Context, enabled: Boolean) {
prefs(context).edit().putBoolean(KEY_LENIENT_SSL, enabled).apply()
}
/** 片头片尾设置:introMs = 片头结束位置;outroMs = 片尾长度(距结尾多少毫秒开始算片尾) */ /** 片头片尾设置:introMs = 片头结束位置;outroMs = 片尾长度(距结尾多少毫秒开始算片尾) */
data class SkipConfig(val introMs: Long, val outroMs: Long) { data class SkipConfig(val introMs: Long, val outroMs: Long) {
val isEmpty: Boolean get() = introMs <= 0 && outroMs <= 0 val isEmpty: Boolean get() = introMs <= 0 && outroMs <= 0
@@ -6,7 +6,7 @@ import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
/** /**
* 使用 SharedPreferences 持久化收藏列表。 * 使用内存缓存 + SharedPreferences 持久化收藏列表。
*/ */
class BookmarkRepository(context: Context) { class BookmarkRepository(context: Context) {
@@ -15,18 +15,25 @@ class BookmarkRepository(context: Context) {
private val keyInitialized = "bookmarks_initialized" private val keyInitialized = "bookmarks_initialized"
fun getAll(): List<Bookmark> { fun getAll(): List<Bookmark> {
val isInitialized = prefs.getBoolean(keyInitialized, false) cachedBookmarks?.let { return it }
val json = prefs.getString(keyBookmarks, null) synchronized(Companion) {
if (!isInitialized && json == null) { cachedBookmarks?.let { return it }
save(DEFAULT_BOOKMARKS) val isInitialized = prefs.getBoolean(keyInitialized, false)
prefs.edit() val json = prefs.getString(keyBookmarks, null)
.putBoolean(keyInitialized, true) if (!isInitialized && json == null) {
.putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION) save(DEFAULT_BOOKMARKS)
.apply() prefs.edit()
return DEFAULT_BOOKMARKS .putBoolean(keyInitialized, true)
.putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION)
.apply()
cachedBookmarks = DEFAULT_BOOKMARKS
return DEFAULT_BOOKMARKS
}
val list = parse(json)
val migrated = migrateDefaults(list)
cachedBookmarks = migrated
return migrated
} }
val list = parse(json)
return migrateDefaults(list)
} }
private fun parse(json: String?): List<Bookmark> { private fun parse(json: String?): List<Bookmark> {
@@ -75,14 +82,18 @@ class BookmarkRepository(context: Context) {
fun contains(url: String): Boolean = getAll().any { it.url == url } fun contains(url: String): Boolean = getAll().any { it.url == url }
private fun save(list: List<Bookmark>) { private fun save(list: List<Bookmark>) {
val arr = JSONArray() val snapshot = list.toList()
list.forEach { b -> synchronized(Companion) {
arr.put(JSONObject().apply { cachedBookmarks = snapshot
put(KEY_URL, b.url) val arr = JSONArray()
put(KEY_TITLE, b.title) snapshot.forEach { b ->
}) arr.put(JSONObject().apply {
put(KEY_URL, b.url)
put(KEY_TITLE, b.title)
})
}
prefs.edit().putString(keyBookmarks, arr.toString()).apply()
} }
prefs.edit().putString(keyBookmarks, arr.toString()).apply()
} }
companion object { companion object {
@@ -90,6 +101,8 @@ class BookmarkRepository(context: Context) {
private const val KEY_URL = "url" private const val KEY_URL = "url"
private const val KEY_TITLE = "title" private const val KEY_TITLE = "title"
private const val KEY_DEFAULTS_VERSION = "bookmarks_defaults_version" private const val KEY_DEFAULTS_VERSION = "bookmarks_defaults_version"
@Volatile
private var cachedBookmarks: List<Bookmark>? = null
/** 默认收藏版本:新增默认站点时 +1,并在 [DEFAULTS_ADDED_BY_VERSION] 登记 */ /** 默认收藏版本:新增默认站点时 +1,并在 [DEFAULTS_ADDED_BY_VERSION] 登记 */
private const val DEFAULTS_VERSION = 4 private const val DEFAULTS_VERSION = 4
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,523 @@
package com.tv.browser
import android.app.UiModeManager
import android.content.Context
import android.content.res.Configuration
import android.net.Uri
import android.os.SystemClock
import android.util.TypedValue
import android.view.KeyEvent
import android.view.View
import android.view.inputmethod.InputMethodManager
import android.webkit.WebStorage
import android.webkit.WebView
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import com.tv.browser.databinding.ActivityBrowserBinding
import java.io.File
import java.util.Calendar
/**
* 浏览器界面与设置弹窗辅助组件(从 BrowserActivity 拆分):
* 负责 TV/手机自适应响应式布局、首页追剧横幅焦点流转、缓存与上传目录清理、以及各类设置/诊断对话框。
*/
object BrowserUiHelper {
private const val FILTER_MENU_TEXT = "🛡️ 不良动图过滤"
private const val VOD_SEARCH_MENU_TEXT = "🔎 全网搜片(在收藏的影视站里搜)"
fun isTelevisionDevice(context: Context): Boolean {
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager
return uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
}
fun isCompactScreen(context: Context): Boolean {
if (isTelevisionDevice(context)) return false
return context.resources.configuration.screenWidthDp < 600
}
fun calculateHomeSpanCount(context: Context): Int {
if (isTelevisionDevice(context)) return 5
val widthDp = context.resources.configuration.screenWidthDp
return when {
widthDp >= 900 -> 5
widthDp >= 700 -> 4
widthDp >= 480 -> 3
else -> 2
}
}
private fun dp(context: Context, value: Int): Int =
TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), context.resources.displayMetrics).toInt()
/** 自适应屏幕布局:TV 保持 5 列大按钮,手机窄屏缩减列数与按钮尺寸 */
fun applyResponsiveLayout(context: Context, binding: ActivityBrowserBinding) {
val compact = isCompactScreen(context)
(binding.homeGrid.layoutManager as? GridLayoutManager)?.spanCount = calculateHomeSpanCount(context)
if (compact) {
binding.homeGrid.setPadding(dp(context, 12), dp(context, 8), dp(context, 12), dp(context, 12))
binding.continueHeader.setPadding(dp(context, 14), 0, dp(context, 14), 0)
binding.continueList.setPadding(dp(context, 12), dp(context, 1), dp(context, 12), dp(context, 2))
binding.continueHint.text = "(点击续播 · 长按移除)"
binding.toolbar.setPadding(dp(context, 12), dp(context, 8), dp(context, 12), dp(context, 8))
} else {
binding.homeGrid.setPadding(dp(context, 20), dp(context, 4), dp(context, 20), dp(context, 10))
binding.continueHeader.setPadding(dp(context, 24), 0, dp(context, 24), 0)
binding.continueList.setPadding(dp(context, 20), dp(context, 1), dp(context, 20), dp(context, 2))
binding.continueHint.text = "(确定键续播 · 长按移除)"
binding.toolbar.setPadding(dp(context, 24), dp(context, 12), dp(context, 24), dp(context, 12))
}
val btnHeight = if (compact) dp(context, 40) else dp(context, 48)
val btnMinWidth = if (compact) dp(context, 60) else dp(context, 80)
val btnTextSp = if (compact) 13f else 14f
val btnPadH = if (compact) dp(context, 10) else dp(context, 12)
val normalButtons = listOf(
binding.btnMouseMode,
binding.btnHome,
binding.btnRefresh,
binding.btnBookmark,
binding.btnRemote,
binding.btnHistory,
binding.btnMore,
binding.btnGo
)
normalButtons.forEach { btn ->
btn.layoutParams = btn.layoutParams.apply { height = btnHeight }
btn.minHeight = btnHeight
btn.minWidth = btnMinWidth
btn.setPadding(btnPadH, 0, btnPadH, 0)
btn.setTextSize(TypedValue.COMPLEX_UNIT_SP, btnTextSp)
}
binding.btnNativePlay.let { btn ->
btn.layoutParams = btn.layoutParams.apply { height = btnHeight }
btn.minHeight = btnHeight
btn.minWidth = if (compact) dp(context, 76) else dp(context, 96)
btn.setPadding(btnPadH, 0, btnPadH, 0)
btn.setTextSize(TypedValue.COMPLEX_UNIT_SP, btnTextSp)
}
binding.btnVoiceSearch.let { btn ->
btn.layoutParams = btn.layoutParams.apply { height = btnHeight }
btn.minHeight = btnHeight
btn.minWidth = if (compact) dp(context, 60) else dp(context, 76)
btn.setPadding(btnPadH, 0, btnPadH, 0)
btn.setTextSize(TypedValue.COMPLEX_UNIT_SP, btnTextSp)
}
binding.remoteBtnContainer.layoutParams = binding.remoteBtnContainer.layoutParams.apply {
height = btnHeight
}
(binding.urlBar.layoutParams as? LinearLayout.LayoutParams)?.let { lp ->
lp.width = if (compact) dp(context, 160) else dp(context, 180)
lp.height = btnHeight
lp.marginStart = if (compact) dp(context, 8) else dp(context, 16)
lp.marginEnd = if (compact) dp(context, 6) else dp(context, 8)
binding.urlBar.layoutParams = lp
}
val urlPadH = if (compact) dp(context, 12) else dp(context, 16)
binding.urlBar.setPadding(urlPadH, 0, urlPadH, 0)
binding.urlBar.setTextSize(TypedValue.COMPLEX_UNIT_SP, if (compact) 14f else 18f)
}
/** 首页"继续观看"追剧列表的遥控器焦点流转 */
fun handleHomeBannerFocus(
keyCode: Int,
isOnHomePage: Boolean,
currentFocus: View?,
binding: ActivityBrowserBinding,
focusContinueList: () -> Boolean
): Boolean {
if (!isOnHomePage || binding.layoutContinueWatching.visibility != View.VISIBLE) return false
val focus = currentFocus ?: return false
fun View.isInside(parent: View): Boolean {
var v: View? = this
while (v != null) {
if (v == parent) return true
v = v.parent as? View
}
return false
}
when (keyCode) {
KeyEvent.KEYCODE_DPAD_DOWN -> {
if (focus.isInside(binding.toolbar)) return focusContinueList()
if (focus.isInside(binding.layoutContinueWatching)) {
val first = binding.homeGrid.findViewHolderForAdapterPosition(0)?.itemView
return first?.requestFocus() ?: true
}
}
KeyEvent.KEYCODE_DPAD_UP -> {
if (focus.isInside(binding.homeGrid) && focus != binding.homeGrid) {
val item = binding.homeGrid.findContainingItemView(focus) ?: return false
val pos = binding.homeGrid.getChildAdapterPosition(item)
val span = (binding.homeGrid.layoutManager as? GridLayoutManager)?.spanCount ?: 1
if (pos in 0 until span) return focusContinueList()
}
if (focus.isInside(binding.layoutContinueWatching)) {
return binding.btnMouseMode.requestFocus()
}
}
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> {
if (focus.isInside(binding.continueList)) {
val item = binding.continueList.findContainingItemView(focus) ?: return false
val pos = binding.continueList.getChildAdapterPosition(item)
val last = (binding.continueList.adapter?.itemCount ?: 0) - 1
if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT && pos == 0) return true
if (keyCode == KeyEvent.KEYCODE_DPAD_RIGHT && pos == last) return true
}
}
}
return false
}
fun clearBrowserCache(context: Context, webView: WebView, showToast: Boolean = true) {
try {
webView.clearCache(true)
webView.clearHistory()
} catch (_: Exception) { }
try {
WebStorage.getInstance().deleteAllData()
} catch (_: Exception) { }
try {
File(context.cacheDir, "WebView").deleteRecursively()
} catch (_: Exception) { }
try {
context.getExternalFilesDir("uploads")?.deleteRecursively()
File(context.filesDir, "uploads").deleteRecursively()
} catch (_: Exception) { }
if (showToast) {
Toast.makeText(context, context.getString(R.string.cache_cleared), Toast.LENGTH_SHORT).show()
}
}
fun cleanExpiredUploadFilesAsync(context: Context) {
val extUploads = context.getExternalFilesDir("uploads")
val intUploads = File(context.filesDir, "uploads")
Thread {
try {
val expireBefore = System.currentTimeMillis() - 3L * 24 * 3600 * 1000
listOfNotNull(extUploads, intUploads).forEach { dir ->
dir.listFiles()?.forEach { f ->
if (f.isFile && f.lastModified() in 1 until expireBefore) {
f.delete()
}
}
}
} catch (_: Exception) { }
}.start()
}
fun ensureWebViewCacheDirs(context: Context) {
try {
val cacheDir = context.cacheDir ?: return
val base = File(cacheDir, "WebView/Default")
base.mkdirs()
File(base, "HTTP Cache/Code Cache/js").mkdirs()
} catch (_: Exception) { }
}
fun showContentFilterDialog(
activity: BrowserActivity,
webView: WebView,
isOnHomePage: Boolean,
onFilterChanged: () -> Unit
) {
val strong = ContentFilterHelper.isStrongFilterEnabled(activity)
val noImage = ContentFilterHelper.isNoImageMode(activity)
val host = if (isOnHomePage) null else webView.url
?.takeIf { it.startsWith("http", ignoreCase = true) }
?.let { runCatching { Uri.parse(it).host }.getOrNull() }
val siteWhitelisted = host != null && ContentFilterHelper.isWhitelisted(activity, host)
val labels = mutableListOf<String>()
val actions = mutableListOf<() -> Unit>()
labels.add("强力过滤可疑动图(当前:${if (strong) "开启" else "关闭"})")
actions.add {
ContentFilterHelper.setStrongFilterEnabled(activity, !strong)
Toast.makeText(activity, if (!strong) "已开启强力过滤" else "已关闭强力过滤", Toast.LENGTH_SHORT).show()
}
if (host != null && strong) {
val domain = ContentFilterHelper.registrableDomain(host) ?: host
labels.add(if (siteWhitelisted) "本站 $domain(当前:不过滤)· 点此恢复过滤" else "本站 $domain(当前:过滤中)· 误伤点此不过滤")
actions.add {
ContentFilterHelper.setWhitelisted(activity, host, !siteWhitelisted)
Toast.makeText(activity, if (siteWhitelisted) "已恢复过滤:$domain" else "本站不再过滤:$domain", Toast.LENGTH_SHORT).show()
}
}
labels.add("无图模式(当前:${if (noImage) "开启" else "关闭"})· 不加载任何网页图片")
actions.add {
ContentFilterHelper.setNoImageMode(activity, !noImage)
webView.settings.blockNetworkImage = !noImage
Toast.makeText(activity, if (!noImage) "已开启无图模式" else "已关闭无图模式", Toast.LENGTH_SHORT).show()
}
val lenientSsl = AppSettings.isLenientSsl(activity)
labels.add("宽松 SSL 证书兼容(当前:${if (lenientSsl) "开启" else "仅日期兼容"})· 解决电视时钟未同步白屏")
actions.add {
AppSettings.setLenientSsl(activity, !lenientSsl)
Toast.makeText(
activity,
if (!lenientSsl) "已开启宽松 SSL 兼容模式" else "已切换为严格 SSL 校验(仅放行电视时钟偏差)",
Toast.LENGTH_SHORT
).show()
}
AlertDialog.Builder(activity)
.setTitle(FILTER_MENU_TEXT)
.setItems(labels.toTypedArray()) { _, which ->
actions[which]()
onFilterChanged()
}
.setNegativeButton(activity.getString(R.string.close), null)
.show()
}
fun showSearchEngineDialog(context: Context, onChanged: (() -> Unit)? = null) {
val engines = SearchEngineHelper.ENGINES
val currentId = SearchEngineHelper.getDefaultEngine(context).id
val names = engines.map { if (it.id == currentId) "● ${it.name} (当前默认)" else " ${it.name}" }.toTypedArray()
AlertDialog.Builder(context)
.setTitle("选择默认搜索引擎")
.setItems(names) { _, which ->
val chosen = engines[which]
SearchEngineHelper.setDefaultEngine(context, chosen.id)
onChanged?.invoke()
Toast.makeText(context, "默认搜索引擎已切换为:${chosen.name}", Toast.LENGTH_SHORT).show()
}
.setNegativeButton("取消", null)
.show()
}
fun showPlaybackHistoryDialog(
context: Context,
repo: PlaybackHistoryRepository,
continueWatchingView: View
) {
val records = repo.getAll()
if (records.isEmpty()) {
Toast.makeText(context, "暂无播放历史记录", Toast.LENGTH_SHORT).show()
return
}
val items = records.map {
"${it.title}\n进度:${it.formatPosition()} / ${it.formatDuration()} (${it.getProgressPercent()}%)"
}.toTypedArray()
AlertDialog.Builder(context)
.setTitle("播放历史记录 (${records.size})")
.setItems(items) { _, which ->
val r = records[which]
PlayerActivity.start(context, r.url, r.title)
}
.setPositiveButton("清空历史") { _, _ ->
repo.clearAll()
continueWatchingView.visibility = View.GONE
Toast.makeText(context, "播放历史已清空", Toast.LENGTH_SHORT).show()
}
.setNegativeButton("关闭", null)
.show()
}
fun showZoomDialog(
context: Context,
currentZoomLevel: Int,
onSelectZoom: (Int) -> Unit
) {
val labels = arrayOf(
"50%(极小 · 宽屏全览)",
"75%(较小 · 适合桌面版)",
"90%(适度缩小)",
"100%(标准默认)",
"125%(放大 · 推荐)",
"150%(大字清晰)",
"175%(超大模式)",
"200%(特大视界)"
)
val currentIdx = BrowserActivity.ZOOM_OPTIONS.indexOf(currentZoomLevel).coerceAtLeast(0)
AlertDialog.Builder(context)
.setTitle("网页缩放比例")
.setSingleChoiceItems(labels, currentIdx) { dialog, which ->
val newZoom = BrowserActivity.ZOOM_OPTIONS[which]
onSelectZoom(newZoom)
Toast.makeText(context, "已设置网页缩放为 ${newZoom}%", Toast.LENGTH_SHORT).show()
dialog.dismiss()
}
.setNegativeButton(context.getString(R.string.close), null)
.show()
}
fun checkNetworkDiagnostics(activity: AppCompatActivity) {
val progress = AlertDialog.Builder(activity)
.setTitle(activity.getString(R.string.check_network))
.setMessage(activity.getString(R.string.network_checking))
.setCancelable(false)
.create()
progress.show()
Thread {
val linkStatus = NetworkChecker.getStatusMessage(activity)
val internetReachable = NetworkChecker.isInternetReachable()
val internetStatus = if (internetReachable) {
activity.getString(R.string.internet_ok)
} else {
activity.getString(R.string.internet_unreachable)
}
val currentYear = Calendar.getInstance().get(Calendar.YEAR)
val timeTip = if (currentYear < 2024) "\n⚠️ 提示:检测到电视系统年份为 ${currentYear} 年,系统时钟可能未同步,浏览器已开启 SSL 兼容放行模式。" else ""
activity.runOnUiThread {
if (!activity.isFinishing && !activity.isDestroyed) {
progress.dismiss()
AlertDialog.Builder(activity)
.setTitle(activity.getString(R.string.check_network))
.setMessage("$linkStatus\n$internetStatus$timeTip")
.setPositiveButton(activity.getString(R.string.close), null)
.show()
}
}
}.start()
}
fun showMoreMenu(
activity: BrowserActivity,
sniffedCount: Int,
hasVideo: Boolean,
onVodSearch: () -> Unit,
onPlaybackHistory: () -> Unit,
onLaunchNative: () -> Unit,
onLaunchExternal: () -> Unit
) {
fun onOff(on: Boolean) = if (on) "开启" else "关闭"
val items = mutableListOf<Pair<String, () -> Unit>>()
items.add("⚙️ 系统设置中心(播放 / 浏览 / 安全 / 存储)" to { activity.showSettingsDialog() })
if (hasVideo) {
val lineCount = sniffedCount.coerceAtLeast(1)
items.add("🎬 原生播放器全屏播放(${lineCount} 条线路)" to onLaunchNative)
items.add("📺 调用外部播放器(MX / Kodi / VLC)" to onLaunchExternal)
}
items.add(VOD_SEARCH_MENU_TEXT to onVodSearch)
items.add("⏱️ 播放历史记录" to onPlaybackHistory)
items.add("自动全屏播放(当前:${onOff(AppSettings.isAutoTakeover(activity))})" to { activity.toggleAutoTakeover() })
items.add("净化播放页(当前:${onOff(AppSettings.isPurifyPage(activity))})" to { activity.togglePurifyPage() })
items.add("浏览器标识(当前:${if (activity.isDesktopUa) "电脑桌面版" else "移动手机版"})" to { activity.toggleUserAgent() })
items.add("清除缓存" to { activity.clearBrowserCache(showToast = true) })
items.add("关于" to { activity.showSettingsDialog(SettingsDialog.TAB_ABOUT) })
AlertDialog.Builder(activity)
.setTitle(activity.getString(R.string.more))
.setItems(items.map { it.first }.toTypedArray()) { _, which -> items[which].second() }
.setNegativeButton(activity.getString(R.string.close), null)
.show()
}
fun updateContinueWatchingBanner(
activity: AppCompatActivity,
binding: ActivityBrowserBinding,
repo: PlaybackHistoryRepository,
limit: Int
) {
val list = binding.continueList
val adapter = list.adapter as? ContinueWatchingAdapter ?: ContinueWatchingAdapter(
onPlay = { record -> PlayerActivity.start(activity, record.url, record.title) },
onRemove = { record, pos ->
repo.remove(record.url)
Toast.makeText(activity, "已从继续观看移除:${record.displayTitle()}", Toast.LENGTH_SHORT).show()
updateContinueWatchingBanner(activity, binding, repo, limit)
val count = list.adapter?.itemCount ?: 0
if (count > 0) {
val target = pos.coerceIn(0, count - 1)
list.post { list.findViewHolderForAdapterPosition(target)?.itemView?.requestFocus() }
} else {
binding.btnMouseMode.requestFocus()
}
}
).also {
list.layoutManager = LinearLayoutManager(activity, LinearLayoutManager.HORIZONTAL, false)
list.itemAnimator = null
list.adapter = it
}
val records = repo.getRecent(limit)
adapter.submit(records)
binding.layoutContinueWatching.visibility = if (records.isNotEmpty()) View.VISIBLE else View.GONE
}
fun focusContinueList(binding: ActivityBrowserBinding): Boolean {
val list = binding.continueList
val first = list.findViewHolderForAdapterPosition(0)?.itemView ?: list.getChildAt(0)
if (first != null) return first.requestFocus()
if ((list.adapter?.itemCount ?: 0) == 0) return false
list.scrollToPosition(0)
list.post { list.findViewHolderForAdapterPosition(0)?.itemView?.requestFocus() }
return true
}
fun hideKeyboard(activity: AppCompatActivity) {
val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager ?: return
val token = activity.currentFocus?.windowToken ?: activity.window.decorView.windowToken
imm.hideSoftInputFromWindow(token, 0)
}
fun showUrlBarKeyboard(activity: AppCompatActivity, urlBar: EditText) {
if (!urlBar.hasFocus()) urlBar.requestFocus()
urlBar.selectAll()
val imm = activity.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager ?: return
if (!imm.showSoftInput(urlBar, 0)) {
urlBar.postDelayed({ if (urlBar.hasFocus()) imm.showSoftInput(urlBar, 0) }, 120)
}
}
fun setupUrlBarKeys(activity: AppCompatActivity, urlBar: EditText, onGo: () -> Unit) {
urlBar.setOnKeyListener { _, keyCode, event ->
if (event == null) return@setOnKeyListener false
val softKey = (event.flags and KeyEvent.FLAG_SOFT_KEYBOARD) != 0
val imeVisible = ViewCompat.getRootWindowInsets(urlBar)?.isVisible(WindowInsetsCompat.Type.ime()) == true
when (keyCode) {
KeyEvent.KEYCODE_DPAD_CENTER -> {
if (event.action == KeyEvent.ACTION_UP) showUrlBarKeyboard(activity, urlBar)
true
}
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
if (softKey || imeVisible) {
if (event.action == KeyEvent.ACTION_DOWN) onGo()
} else if (event.action == KeyEvent.ACTION_UP) {
showUrlBarKeyboard(activity, urlBar)
}
true
}
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_RIGHT -> {
if (softKey || imeVisible) return@setOnKeyListener false
if (event.action == KeyEvent.ACTION_DOWN) {
val dir = if (keyCode == KeyEvent.KEYCODE_DPAD_LEFT) View.FOCUS_LEFT else View.FOCUS_RIGHT
urlBar.focusSearch(dir)?.requestFocus(dir)
}
true
}
else -> false
}
}
}
fun isHijackNavigation(
targetUrl: String,
currentPageHost: String?,
lastUserLink: String?,
lastUserLinkTime: Long
): Boolean {
val targetDomain = ContentFilterHelper.registrableDomain(runCatching { Uri.parse(targetUrl).host }.getOrNull())
val pageDomain = ContentFilterHelper.registrableDomain(currentPageHost)
if (targetDomain == null || pageDomain == null || targetDomain == pageDomain) return false
if (!lastUserLink.isNullOrBlank() && SystemClock.uptimeMillis() - lastUserLinkTime < 3000) {
if (lastUserLink == targetUrl) return false
val linkDomain = ContentFilterHelper.registrableDomain(runCatching { Uri.parse(lastUserLink).host }.getOrNull())
if (linkDomain == targetDomain) return false
}
return true
}
}
@@ -38,32 +38,51 @@ object ContentFilterHelper {
// ---------------- 开关 ---------------- // ---------------- 开关 ----------------
/** 内存缓存(shouldInterceptRequest 在后台线程高频读取,避免反复触发 SharedPreferences 锁) */
@Volatile private var cachedStrongFilter: Boolean? = null
@Volatile private var cachedNoImage: Boolean? = null
@Volatile private var cachedWhitelist: Set<String>? = null
/** 强力过滤可疑动图(默认开启) */ /** 强力过滤可疑动图(默认开启) */
fun isStrongFilterEnabled(context: Context): Boolean = fun isStrongFilterEnabled(context: Context): Boolean {
prefs(context).getBoolean(KEY_STRONG_FILTER, true) cachedStrongFilter?.let { return it }
val enabled = prefs(context).getBoolean(KEY_STRONG_FILTER, true)
cachedStrongFilter = enabled
return enabled
}
fun setStrongFilterEnabled(context: Context, enabled: Boolean) { fun setStrongFilterEnabled(context: Context, enabled: Boolean) {
cachedStrongFilter = enabled
prefs(context).edit().putBoolean(KEY_STRONG_FILTER, enabled).apply() prefs(context).edit().putBoolean(KEY_STRONG_FILTER, enabled).apply()
} }
/** 无图模式(默认关闭) */ /** 无图模式(默认关闭) */
fun isNoImageMode(context: Context): Boolean = fun isNoImageMode(context: Context): Boolean {
prefs(context).getBoolean(KEY_NO_IMAGE, false) cachedNoImage?.let { return it }
val enabled = prefs(context).getBoolean(KEY_NO_IMAGE, false)
cachedNoImage = enabled
return enabled
}
fun setNoImageMode(context: Context, enabled: Boolean) { fun setNoImageMode(context: Context, enabled: Boolean) {
cachedNoImage = enabled
prefs(context).edit().putBoolean(KEY_NO_IMAGE, enabled).apply() prefs(context).edit().putBoolean(KEY_NO_IMAGE, enabled).apply()
} }
/** 站点是否在白名单(不过滤)中,按主域判断 */ /** 站点是否在白名单(不过滤)中,按主域判断 */
fun isWhitelisted(context: Context, host: String?): Boolean { fun isWhitelisted(context: Context, host: String?): Boolean {
val domain = registrableDomain(host) ?: return false val domain = registrableDomain(host) ?: return false
return prefs(context).getStringSet(KEY_WHITELIST, emptySet())?.contains(domain) == true val list = cachedWhitelist ?: prefs(context).getStringSet(KEY_WHITELIST, emptySet()).orEmpty().toSet().also {
cachedWhitelist = it
}
return list.contains(domain)
} }
fun setWhitelisted(context: Context, host: String?, whitelisted: Boolean) { fun setWhitelisted(context: Context, host: String?, whitelisted: Boolean) {
val domain = registrableDomain(host) ?: return val domain = registrableDomain(host) ?: return
val set = prefs(context).getStringSet(KEY_WHITELIST, emptySet()).orEmpty().toMutableSet() val set = (cachedWhitelist ?: prefs(context).getStringSet(KEY_WHITELIST, emptySet()).orEmpty()).toMutableSet()
if (whitelisted) set.add(domain) else set.remove(domain) if (whitelisted) set.add(domain) else set.remove(domain)
cachedWhitelist = set.toSet()
prefs(context).edit().putStringSet(KEY_WHITELIST, set).apply() prefs(context).edit().putStringSet(KEY_WHITELIST, set).apply()
} }
@@ -0,0 +1,233 @@
package com.tv.browser
import android.content.Context
import android.os.SystemClock
import android.util.TypedValue
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.webkit.WebView
import android.widget.ImageView
import androidx.fragment.app.Fragment
import java.util.Locale
/**
* 模拟鼠标光标控制器(从 BrowserActivity 拆分):
* 负责遥控器 D-pad 模拟鼠标位置计算、边界收紧、边缘滚动、内部滚动区滚动、光标处点击派发与显隐状态管理。
*/
class CursorController(
private val context: Context,
private val cursorView: ImageView?,
private val cursorOverlay: ViewGroup?,
private val contentLayout: ViewGroup?,
private val webView: WebView,
private val toolbarScroll: View,
private val toolbar: View,
private val webContainer: View
) {
var cursorX: Float = 0f
private set
var cursorY: Float = 0f
private set
private var cursorStepPx: Int = 9
private val edgeScrollPx: Int
get() = (48 * context.resources.displayMetrics.density).toInt()
/** 光标隐藏原因集合(弹窗、网页全屏视频等),按来源对象去重,幂等防泄漏 */
private val cursorHideTokens = LinkedHashSet<Any>()
val customViewCursorToken = Any()
init {
cursorView?.isFocusable = false
cursorView?.isClickable = false
// FrameLayout(clickable=false) 默认将触摸事件穿透给下层 contentLayout
val passThrough = View.OnTouchListener { _, _ -> false }
cursorView?.setOnTouchListener(passThrough)
cursorOverlay?.setOnTouchListener(passThrough)
cursorOverlay?.post { initPosition() }
}
/** 初始化模拟鼠标箭头位置为全屏中心(含工具栏区域) */
fun initPosition() {
val overlay = cursorOverlay ?: return
val cursor = cursorView ?: return
cursorStepPx = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 9f, context.resources.displayMetrics
).toInt()
val cw = overlay.width
val ch = overlay.height
val cwv = cursor.width
val chv = cursor.height
if (cw <= 0 || ch <= 0) return
cursorX = ((cw - cwv) / 2f).coerceIn(0f, (cw - cwv).toFloat())
cursorY = ((ch - chv) / 2f).coerceIn(0f, (ch - chv).toFloat())
cursor.x = cursorX
cursor.y = cursorY
}
/** 按步长移动光标并刷新位置;仅当有垂直移动(dy!=0)时触发边缘滚动 */
fun move(dx: Int, dy: Int, isOnHomePage: Boolean, onReachTopEdge: () -> Unit) {
val overlay = cursorOverlay ?: return
val cursor = cursorView ?: return
val ow = overlay.width
val oh = overlay.height
val cw = cursor.width
val ch = cursor.height
val maxX = (ow - cw).toFloat().coerceAtLeast(0f)
val maxY = (oh - ch).toFloat().coerceAtLeast(0f)
cursorX = (cursorX + dx).coerceIn(0f, maxX)
cursorY = (cursorY + dy).coerceIn(0f, maxY)
cursor.x = cursorX
cursor.y = cursorY
if (dy != 0) {
performEdgeScrollIfNeeded(dy, isOnHomePage)
if (!isOnHomePage && cursorY < 64 * context.resources.displayMetrics.density) {
onReachTopEdge()
}
}
}
/** 将光标限制在全屏 overlay 内(如全屏切换后) */
fun clampToBounds() {
val overlay = cursorOverlay ?: return
val cursor = cursorView ?: return
if (overlay.width <= 0 || overlay.height <= 0) return
val maxX = (overlay.width - cursor.width).toFloat().coerceAtLeast(0f)
val maxY = (overlay.height - cursor.height).toFloat().coerceAtLeast(0f)
val newX = cursorX.coerceIn(0f, maxX)
val newY = cursorY.coerceIn(0f, maxY)
if (newX != cursorX || newY != cursorY) {
cursorX = newX
cursorY = newY
cursor.x = cursorX
cursor.y = cursorY
}
}
/** 在光标位置向整页(contentLayout)派发一次点击,可点到工具栏或 WebView */
fun dispatchClick() {
val content = contentLayout ?: return
val now = SystemClock.uptimeMillis()
val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, cursorX, cursorY, 0)
content.dispatchTouchEvent(down)
down.recycle()
val up = MotionEvent.obtain(now, now + 50, MotionEvent.ACTION_UP, cursorX, cursorY, 0)
content.dispatchTouchEvent(up)
up.recycle()
}
/** 焦点是否在网页内容区且应使用模拟鼠标(首页卡片或工具栏按钮走正常 D-pad 焦点流转) */
fun isFocusInContent(focus: View?, isOnHomePage: Boolean): Boolean {
if (isOnHomePage) return false
if (focus == null) return true
var v: View? = focus
while (v != null) {
if (v == toolbar) return false
if (v == webContainer || v == webView) return true
v = v.parent as? View
}
return false
}
/** 处理网页内容区的 D-pad 模拟鼠标移动与点击 */
fun handleKeyEvent(
event: KeyEvent,
isOnHomePage: Boolean,
isMouseMode: Boolean,
currentFocus: View?,
onReachTopEdge: () -> Unit
): Boolean {
if (!isMouseMode || !isFocusInContent(currentFocus, isOnHomePage)) return false
val keyCode = event.keyCode
val action = event.action
if (action == KeyEvent.ACTION_DOWN) {
val repeat = event.repeatCount
val accelStep = (cursorStepPx * (1f + (repeat * 0.35f).coerceAtMost(3.0f))).toInt()
when (keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> { move(-accelStep, 0, isOnHomePage, onReachTopEdge); return true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { move(accelStep, 0, isOnHomePage, onReachTopEdge); return true }
KeyEvent.KEYCODE_DPAD_UP -> { move(0, -accelStep, isOnHomePage, onReachTopEdge); return true }
KeyEvent.KEYCODE_DPAD_DOWN -> { move(0, accelStep, isOnHomePage, onReachTopEdge); return true }
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> return true
}
} else if (action == KeyEvent.ACTION_UP) {
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
dispatchClick()
return true
}
if (keyCode in listOf(
KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT,
KeyEvent.KEYCODE_DPAD_UP,
KeyEvent.KEYCODE_DPAD_DOWN
)
) {
return true
}
}
return false
}
fun setVisible(
visible: Boolean,
token: Any,
isOnHomePage: Boolean,
isMouseMode: Boolean,
isCustomViewFullscreen: Boolean
) {
if (visible) cursorHideTokens.remove(token) else cursorHideTokens.add(token)
updateVisibility(isOnHomePage, isMouseMode, isCustomViewFullscreen)
}
fun updateVisibility(
isOnHomePage: Boolean,
isMouseMode: Boolean,
isCustomViewFullscreen: Boolean
) {
cursorHideTokens.removeAll { it is Fragment && !it.isAdded }
if (!isCustomViewFullscreen) cursorHideTokens.remove(customViewCursorToken)
val shouldShow = !isOnHomePage && isMouseMode && cursorHideTokens.isEmpty()
cursorOverlay?.visibility = if (shouldShow) View.VISIBLE else View.GONE
}
private fun performEdgeScrollIfNeeded(dy: Int, isOnHomePage: Boolean) {
if (isOnHomePage) return
val th = if (toolbarScroll.visibility == View.VISIBLE) toolbarScroll.height else 0
val wh = webContainer.height
if (wh <= 0) return
val webTop = th.toFloat()
val webBottom = th + wh
val dir = when {
dy < 0 && cursorY < webTop + edgeScrollPx -> -1
dy > 0 && cursorY > webBottom - edgeScrollPx -> 1
else -> return
}
if (webView.canScrollVertically(dir)) {
webView.scrollBy(0, dir * edgeScrollPx)
} else {
scrollInnerAtCursor(dir)
}
}
private fun scrollInnerAtCursor(dir: Int) {
val overlay = cursorOverlay ?: return
val ww = webView.width
val wh = webView.height
if (ww <= 0 || wh <= 0) return
val wl = IntArray(2).also { webView.getLocationOnScreen(it) }
val ol = IntArray(2).also { overlay.getLocationOnScreen(it) }
val fx = ((cursorX + ol[0] - wl[0]) / ww).coerceIn(0f, 0.999f)
val fy = ((cursorY + ol[1] - wl[1]) / wh).coerceIn(0f, 0.999f)
val fdy = dir * edgeScrollPx.toFloat() / wh
val js = String.format(
Locale.US,
"(function(fx,fy,fdy){try{var d=fdy*innerHeight,n=document.elementFromPoint(fx*innerWidth,fy*innerHeight);" +
"while(n&&n.nodeType===1&&n!==document.body&&n!==document.documentElement){" +
"var s=getComputedStyle(n);if(/(auto|scroll|overlay)/.test(s.overflowY)&&n.scrollHeight>n.clientHeight+1){" +
"var b=n.scrollTop;n.scrollTop=b+d;if(n.scrollTop!==b)return;}n=n.parentElement;}}catch(e){}})(%.4f,%.4f,%.4f);",
fx, fy, fdy
)
webView.evaluateJavascript(js, null)
}
}
@@ -0,0 +1,212 @@
package com.tv.browser
import android.graphics.Color
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.Log
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.widget.FrameLayout
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
/**
* WebView 网页视频全屏管理组件(从 BrowserActivity 拆分):
* 负责 WebView CustomView 原生全屏容器、系统沉浸式全屏切换以及应用级全屏降级。
*/
class CustomViewFullscreenHelper(
private val activity: AppCompatActivity,
private val webView: WebView,
private val toolbarScroll: View,
private val cursorController: CursorController,
private val isOnHomePageProvider: () -> Boolean,
private val isMouseModeProvider: () -> Boolean,
private val runVideoJs: (String, (String) -> Unit) -> Unit
) {
private val mainHandler = Handler(Looper.getMainLooper())
var fullscreenContainer: FrameLayout? = null
private set
private var customViewCallback: WebChromeClient.CustomViewCallback? = null
var isAppFullscreen: Boolean = false
private set
val isCustomViewActive: Boolean
get() = fullscreenContainer != null && fullscreenContainer?.parent != null
val isAnyFullscreen: Boolean
get() = isCustomViewActive || isAppFullscreen
/** 进入全屏 CustomView 处理:支持机型差异、沉浸模式并隐藏工具栏与光标 */
fun showCustomView(view: View?, callback: WebChromeClient.CustomViewCallback?) {
if (view == null || callback == null) return
exitCustomView()
toolbarScroll.visibility = View.GONE
setImmersiveFullscreen(true)
val container = FrameLayout(activity).apply {
setBackgroundColor(Color.BLACK)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
container.addView(
view,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
activity.findViewById<ViewGroup>(android.R.id.content).addView(container)
fullscreenContainer = container
customViewCallback = callback
cursorController.setVisible(
visible = false,
token = cursorController.customViewCursorToken,
isOnHomePage = isOnHomePageProvider(),
isMouseMode = isMouseModeProvider(),
isCustomViewFullscreen = true
)
Toast.makeText(activity, "全屏播放:确认键暂停,左右键快进快退,返回键退出全屏", Toast.LENGTH_SHORT).show()
}
/** 退出 WebView 全屏视频(CustomView),并通知 WebView */
fun exitCustomView() {
val wasFullscreen = fullscreenContainer != null
fullscreenContainer?.let { container ->
(container.parent as? ViewGroup)?.removeView(container)
container.removeAllViews()
}
fullscreenContainer = null
customViewCallback?.onCustomViewHidden()
customViewCallback = null
if (wasFullscreen && !isAppFullscreen) {
toolbarScroll.visibility = View.VISIBLE
setImmersiveFullscreen(false)
}
cursorController.setVisible(
visible = true,
token = cursorController.customViewCursorToken,
isOnHomePage = isOnHomePageProvider(),
isMouseMode = isMouseModeProvider(),
isCustomViewFullscreen = false
)
if (wasFullscreen) {
toolbarScroll.post { cursorController.clampToBounds() }
webView.requestFocus()
}
}
/** 进入应用级全屏:隐藏工具栏,让 WebView 占满屏幕(网页全屏或机型全屏降级) */
fun enterAppFullscreen() {
if (isAppFullscreen) return
isAppFullscreen = true
toolbarScroll.visibility = View.GONE
setImmersiveFullscreen(true)
toolbarScroll.post { cursorController.clampToBounds() }
Toast.makeText(activity, activity.getString(R.string.video_fullscreen_app_mode), Toast.LENGTH_SHORT).show()
}
/** 退出应用级全屏:恢复工具栏 */
fun exitAppFullscreen() {
if (!isAppFullscreen) return
isAppFullscreen = false
toolbarScroll.visibility = View.VISIBLE
setImmersiveFullscreen(false)
toolbarScroll.post { cursorController.clampToBounds() }
}
fun toggleAppFullscreen() {
if (isAppFullscreen) exitAppFullscreen() else enterAppFullscreen()
}
/** 触发页面内视频全屏:先 requestFullscreen,再尝试双击;均未弹出则 1s 后降级为应用级全屏 */
fun triggerVideoFullscreen() {
Log.d(TAG, "triggerVideoFullscreen() called")
runVideoJs(VideoControlHelper.JS_FULLSCREEN) { r ->
if (r != "ok") {
runVideoJs(VideoControlHelper.JS_FULLSCREEN_DBLCLICK) { }
}
}
mainHandler.postDelayed({
if (fullscreenContainer == null && !isAppFullscreen) {
toggleAppFullscreen()
}
}, 1000)
}
/** 全屏视频(CustomView)时的遥控器方向键/确认键控制 */
fun handleKeyEvent(event: KeyEvent): Boolean {
if (event.action != KeyEvent.ACTION_DOWN || !isCustomViewActive) return false
when (event.keyCode) {
KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER ->
runVideoJs(VideoControlHelper.JS_TOGGLE_PLAY) { }
KeyEvent.KEYCODE_DPAD_LEFT ->
runVideoJs(VideoControlHelper.JS_SEEK_BACK) { }
KeyEvent.KEYCODE_DPAD_RIGHT ->
runVideoJs(VideoControlHelper.JS_SEEK_FORWARD) { }
KeyEvent.KEYCODE_DPAD_UP ->
runVideoJs(VideoControlHelper.JS_VOLUME_UP) { }
KeyEvent.KEYCODE_DPAD_DOWN ->
runVideoJs(VideoControlHelper.JS_VOLUME_DOWN) { }
else -> return false
}
return true
}
/** 手机遥控点击:若正处于 CustomView 全屏则在全屏容器中心点击 */
fun handleRemoteClick(): Boolean {
if (!isCustomViewActive) return false
try {
val container = fullscreenContainer!!
val target = if (container.childCount > 0) container.getChildAt(0) else container
val w = target.width
val h = target.height
if (w > 0 && h > 0) {
val now = SystemClock.uptimeMillis()
val x = w / 2f
val y = h / 2f
val down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0)
val up = MotionEvent.obtain(now, now + 50, MotionEvent.ACTION_UP, x, y, 0)
target.dispatchTouchEvent(down)
target.dispatchTouchEvent(up)
down.recycle()
up.recycle()
return true
}
} catch (_: Exception) { }
runVideoJs(VideoControlHelper.JS_TOGGLE_PLAY) { }
return true
}
@Suppress("DEPRECATION")
private fun setImmersiveFullscreen(enable: Boolean) {
if (enable) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
activity.window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN
or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
)
} else {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
}
}
companion object {
private const val TAG = "TVBrowser.Fullscreen"
}
}
@@ -6,7 +6,7 @@ import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
/** /**
* 浏览历史持久化,最多保留 [MAX_ENTRIES] 条。 * 浏览历史持久化(内存缓存 + SharedPreferences 异步落盘),最多保留 [MAX_ENTRIES] 条。
*/ */
class HistoryRepository(context: Context) { class HistoryRepository(context: Context) {
@@ -14,20 +14,29 @@ class HistoryRepository(context: Context) {
private val keyHistory = "history" private val keyHistory = "history"
fun getAll(): List<HistoryItem> { fun getAll(): List<HistoryItem> {
val json = prefs.getString(keyHistory, null) cachedHistory?.let { return it }
if (json.isNullOrBlank()) return emptyList() synchronized(Companion) {
return try { cachedHistory?.let { return it }
val arr = JSONArray(json) val json = prefs.getString(keyHistory, null)
List(arr.length()) { i -> val loaded = if (json.isNullOrBlank()) {
val obj = arr.getJSONObject(i) emptyList()
HistoryItem( } else {
url = obj.getString(KEY_URL), try {
title = obj.optString(KEY_TITLE, ""), val arr = JSONArray(json)
visitedAt = obj.optLong(KEY_VISITED_AT, 0L) List(arr.length()) { i ->
) val obj = arr.getJSONObject(i)
HistoryItem(
url = obj.getString(KEY_URL),
title = obj.optString(KEY_TITLE, ""),
visitedAt = obj.optLong(KEY_VISITED_AT, 0L)
)
}
} catch (_: Exception) {
emptyList()
}
} }
} catch (_: Exception) { cachedHistory = loaded
emptyList() return loaded
} }
} }
@@ -42,19 +51,26 @@ class HistoryRepository(context: Context) {
} }
fun clear() { fun clear() {
prefs.edit().remove(keyHistory).apply() synchronized(Companion) {
cachedHistory = emptyList()
prefs.edit().remove(keyHistory).apply()
}
} }
private fun save(list: List<HistoryItem>) { private fun save(list: List<HistoryItem>) {
val arr = JSONArray() val snapshot = list.toList()
list.forEach { item -> synchronized(Companion) {
arr.put(JSONObject().apply { cachedHistory = snapshot
put(KEY_URL, item.url) val arr = JSONArray()
put(KEY_TITLE, item.title) snapshot.forEach { item ->
put(KEY_VISITED_AT, item.visitedAt) arr.put(JSONObject().apply {
}) put(KEY_URL, item.url)
put(KEY_TITLE, item.title)
put(KEY_VISITED_AT, item.visitedAt)
})
}
prefs.edit().putString(keyHistory, arr.toString()).apply()
} }
prefs.edit().putString(keyHistory, arr.toString()).apply()
} }
companion object { companion object {
@@ -63,5 +79,7 @@ class HistoryRepository(context: Context) {
private const val KEY_TITLE = "title" private const val KEY_TITLE = "title"
private const val KEY_VISITED_AT = "visitedAt" private const val KEY_VISITED_AT = "visitedAt"
const val MAX_ENTRIES = 50 const val MAX_ENTRIES = 50
@Volatile
private var cachedHistory: List<HistoryItem>? = null
} }
} }
@@ -58,31 +58,41 @@ data class PlaybackRecord(
} }
/** /**
* 播放历史持久化管理(SharedPreferences + JSON) * 播放历史持久化管理(内存缓存 + SharedPreferences 异步落盘,避免播放中高频重复解析 JSON)
*/ */
class PlaybackHistoryRepository(context: Context) { class PlaybackHistoryRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun getAll(): List<PlaybackRecord> { fun getAll(): List<PlaybackRecord> {
val json = prefs.getString(KEY_RECORDS, null) ?: return emptyList() cachedRecords?.let { return it }
return try { synchronized(Companion) {
val arr = JSONArray(json) cachedRecords?.let { return it }
List(arr.length()) { i -> val json = prefs.getString(KEY_RECORDS, null)
val obj = arr.getJSONObject(i) val loaded = if (json.isNullOrBlank()) {
PlaybackRecord( emptyList()
url = obj.optString("url", ""), } else {
title = obj.optString("title", ""), try {
positionMs = obj.optInt("positionMs", 0), val arr = JSONArray(json)
durationMs = obj.optInt("durationMs", 0), List(arr.length()) { i ->
timestamp = obj.optLong("timestamp", 0), val obj = arr.getJSONObject(i)
series = obj.optString("series", ""), PlaybackRecord(
seriesTitle = obj.optString("seriesTitle", ""), url = obj.optString("url", ""),
episodeName = obj.optString("episodeName", "") title = obj.optString("title", ""),
) positionMs = obj.optInt("positionMs", 0),
}.filter { it.url.isNotBlank() } durationMs = obj.optInt("durationMs", 0),
} catch (_: Exception) { timestamp = obj.optLong("timestamp", 0),
emptyList() series = obj.optString("series", ""),
seriesTitle = obj.optString("seriesTitle", ""),
episodeName = obj.optString("episodeName", "")
)
}.filter { it.url.isNotBlank() }
} catch (_: Exception) {
emptyList()
}
}
cachedRecords = loaded
return loaded
} }
} }
@@ -161,29 +171,38 @@ class PlaybackHistoryRepository(context: Context) {
} }
fun clearAll() { fun clearAll() {
prefs.edit().remove(KEY_RECORDS).apply() synchronized(Companion) {
cachedRecords = emptyList()
prefs.edit().remove(KEY_RECORDS).apply()
}
} }
private fun save(list: List<PlaybackRecord>) { private fun save(list: List<PlaybackRecord>) {
val arr = JSONArray() val snapshot = list.toList()
list.forEach { r -> synchronized(Companion) {
arr.put(JSONObject().apply { cachedRecords = snapshot
put("url", r.url) val arr = JSONArray()
put("title", r.title) snapshot.forEach { r ->
put("positionMs", r.positionMs) arr.put(JSONObject().apply {
put("durationMs", r.durationMs) put("url", r.url)
put("timestamp", r.timestamp) put("title", r.title)
if (r.series.isNotBlank()) put("series", r.series) put("positionMs", r.positionMs)
if (r.seriesTitle.isNotBlank()) put("seriesTitle", r.seriesTitle) put("durationMs", r.durationMs)
if (r.episodeName.isNotBlank()) put("episodeName", r.episodeName) put("timestamp", r.timestamp)
}) if (r.series.isNotBlank()) put("series", r.series)
if (r.seriesTitle.isNotBlank()) put("seriesTitle", r.seriesTitle)
if (r.episodeName.isNotBlank()) put("episodeName", r.episodeName)
})
}
prefs.edit().putString(KEY_RECORDS, arr.toString()).apply()
} }
prefs.edit().putString(KEY_RECORDS, arr.toString()).apply()
} }
companion object { companion object {
private const val PREFS_NAME = "tv_browser_playback_history" private const val PREFS_NAME = "tv_browser_playback_history"
private const val KEY_RECORDS = "records" private const val KEY_RECORDS = "records"
private const val MAX_RECORDS = 30 private const val MAX_RECORDS = 30
@Volatile
private var cachedRecords: List<PlaybackRecord>? = null
} }
} }
@@ -139,6 +139,9 @@ class PlayerActivity : AppCompatActivity() {
setContentView(binding.root) setContentView(binding.root)
historyRepo = PlaybackHistoryRepository(this) historyRepo = PlaybackHistoryRepository(this)
val savedSpeed = AppSettings.getPlaybackSpeed(this)
val savedSpeedIdx = speeds.indexOfFirst { kotlin.math.abs(it - savedSpeed) < 0.01f }
if (savedSpeedIdx >= 0) currentSpeedIndex = savedSpeedIdx
val rawUrl = intent.getStringExtra(EXTRA_VIDEO_URL).orEmpty() val rawUrl = intent.getStringExtra(EXTRA_VIDEO_URL).orEmpty()
val pageUrlExtra = intent.getStringExtra(EXTRA_PAGE_URL)?.takeIf { it.isNotBlank() } val pageUrlExtra = intent.getStringExtra(EXTRA_PAGE_URL)?.takeIf { it.isNotBlank() }
@@ -300,6 +303,7 @@ class PlayerActivity : AppCompatActivity() {
private fun initViews() { private fun initViews() {
binding.tvVideoTitle.text = videoTitle binding.tvVideoTitle.text = videoTitle
binding.btnSpeed.text = "${speeds[currentSpeedIndex]}x"
binding.btnBack.setOnClickListener { finish() } binding.btnBack.setOnClickListener { finish() }
binding.btnExternal.setOnClickListener { launchExternalPlayer() } binding.btnExternal.setOnClickListener { launchExternalPlayer() }
@@ -1024,6 +1028,7 @@ class PlayerActivity : AppCompatActivity() {
private fun togglePlaybackSpeed() { private fun togglePlaybackSpeed() {
currentSpeedIndex = (currentSpeedIndex + 1) % speeds.size currentSpeedIndex = (currentSpeedIndex + 1) % speeds.size
val targetSpeed = speeds[currentSpeedIndex] val targetSpeed = speeds[currentSpeedIndex]
AppSettings.setPlaybackSpeed(this, targetSpeed)
player?.setPlaybackSpeed(targetSpeed) player?.setPlaybackSpeed(targetSpeed)
binding.btnSpeed.text = "${targetSpeed}x" binding.btnSpeed.text = "${targetSpeed}x"
showFeedback("倍速:${targetSpeed}x") showFeedback("倍速:${targetSpeed}x")
@@ -1407,14 +1412,35 @@ class PlayerActivity : AppCompatActivity() {
/** 触摸选集面板外部区域时直接收起选集面板,并消费本次手势避免误触底层控制栏 */ /** 触摸选集面板外部区域时直接收起选集面板,并消费本次手势避免误触底层控制栏 */
private var dismissingEpisodePanelTouch = false private var dismissingEpisodePanelTouch = false
/** 触屏手势控制(手机端):左右滑动调节进度、左侧上下滑动调节亮度、右侧上下滑动调节音量 */
private var touchGestureEligible = false
private var touchGestureMode = 0 // 0=未触发, 1=水平进度, 2=左侧亮度, 3=右侧音量
private var touchDownX = 0f
private var touchDownY = 0f
private var gestureStartPos = 0L
private var gestureTargetPos = 0L
private var gestureStartBrightness = 0.5f
private var gestureStartVolume = 0
override fun dispatchTouchEvent(ev: android.view.MotionEvent): Boolean { override fun dispatchTouchEvent(ev: android.view.MotionEvent): Boolean {
if (ev.actionMasked == android.view.MotionEvent.ACTION_DOWN) { if (ev.actionMasked == android.view.MotionEvent.ACTION_DOWN) {
if (isEpisodePanelVisible() && !isTouchInsideView(ev.rawX, ev.rawY, binding.episodePanel)) { if (isEpisodePanelVisible() && !isTouchInsideView(ev.rawX, ev.rawY, binding.episodePanel)) {
hideEpisodePanel() hideEpisodePanel()
dismissingEpisodePanelTouch = true dismissingEpisodePanelTouch = true
touchGestureEligible = false
return true return true
} }
dismissingEpisodePanelTouch = false dismissingEpisodePanelTouch = false
touchGestureMode = 0
touchDownX = ev.rawX
touchDownY = ev.rawY
// 仅在非选集面板、且未点在顶部/底部控制栏或暂停浮层上时允许滑动手势
val onOsdBars = isOsdVisible() && (
isTouchInsideView(ev.rawX, ev.rawY, binding.topBar) ||
isTouchInsideView(ev.rawX, ev.rawY, binding.bottomBar)
)
val onPauseOverlay = isTouchInsideView(ev.rawX, ev.rawY, binding.pauseOverlay)
touchGestureEligible = !isEpisodePanelVisible() && !onOsdBars && !onPauseOverlay
} else if (dismissingEpisodePanelTouch) { } else if (dismissingEpisodePanelTouch) {
if (ev.actionMasked == android.view.MotionEvent.ACTION_UP || if (ev.actionMasked == android.view.MotionEvent.ACTION_UP ||
ev.actionMasked == android.view.MotionEvent.ACTION_CANCEL ev.actionMasked == android.view.MotionEvent.ACTION_CANCEL
@@ -1422,6 +1448,90 @@ class PlayerActivity : AppCompatActivity() {
dismissingEpisodePanelTouch = false dismissingEpisodePanelTouch = false
} }
return true return true
} else if (touchGestureEligible) {
when (ev.actionMasked) {
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
touchGestureEligible = false
touchGestureMode = 0
}
android.view.MotionEvent.ACTION_MOVE -> {
val dx = ev.rawX - touchDownX
val dy = ev.rawY - touchDownY
val slop = android.view.ViewConfiguration.get(this).scaledTouchSlop * 1.5f
if (touchGestureMode == 0 && (kotlin.math.abs(dx) > slop || kotlin.math.abs(dy) > slop)) {
val screenW = resources.displayMetrics.widthPixels.coerceAtLeast(1)
if (kotlin.math.abs(dx) > kotlin.math.abs(dy) * 1.2f) {
touchGestureMode = 1
gestureStartPos = player?.currentPosition?.coerceAtLeast(0L) ?: 0L
gestureTargetPos = gestureStartPos
isSeeking = true
} else if (kotlin.math.abs(dy) > kotlin.math.abs(dx) * 1.2f) {
if (touchDownX < screenW / 2f) {
touchGestureMode = 2
val curB = window.attributes.screenBrightness
gestureStartBrightness = if (curB in 0.01f..1.0f) curB else 0.5f
} else {
touchGestureMode = 3
val am = getSystemService(AUDIO_SERVICE) as? android.media.AudioManager
gestureStartVolume = am?.getStreamVolume(android.media.AudioManager.STREAM_MUSIC) ?: 0
}
}
}
when (touchGestureMode) {
1 -> {
val p = player
if (p != null) {
val screenW = resources.displayMetrics.widthPixels.coerceAtLeast(1).toFloat()
val offsetMs = ((dx / screenW) * 180_000f).toLong()
val dur = p.duration
val max = if (dur != C.TIME_UNSET && dur > 0) dur else Long.MAX_VALUE
gestureTargetPos = (gestureStartPos + offsetMs).coerceIn(0L, max)
val realOffsetSec = (gestureTargetPos - gestureStartPos) / 1000
val sign = if (realOffsetSec >= 0) "+" else ""
showFeedback("${sign}${realOffsetSec}s (${formatTime(gestureTargetPos)})")
}
return true
}
2 -> {
val screenH = resources.displayMetrics.heightPixels.coerceAtLeast(1).toFloat()
val delta = -dy / (screenH * 0.75f)
val targetB = (gestureStartBrightness + delta).coerceIn(0.05f, 1.0f)
val lp = window.attributes
lp.screenBrightness = targetB
window.attributes = lp
showFeedback("亮度:${(targetB * 100).toInt()}%")
return true
}
3 -> {
val am = getSystemService(AUDIO_SERVICE) as? android.media.AudioManager
if (am != null) {
val maxVol = am.getStreamMaxVolume(android.media.AudioManager.STREAM_MUSIC).coerceAtLeast(1)
val screenH = resources.displayMetrics.heightPixels.coerceAtLeast(1).toFloat()
val deltaVol = ((-dy / (screenH * 0.75f)) * maxVol).toInt()
val targetVol = (gestureStartVolume + deltaVol).coerceIn(0, maxVol)
am.setStreamVolume(android.media.AudioManager.STREAM_MUSIC, targetVol, 0)
showFeedback("音量:${targetVol * 100 / maxVol}%")
}
return true
}
}
}
android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
val mode = touchGestureMode
touchGestureMode = 0
touchGestureEligible = false
if (mode == 1) {
isSeeking = false
if (ev.actionMasked == android.view.MotionEvent.ACTION_UP) {
player?.seekTo(gestureTargetPos)
updateProgress()
}
return true
} else if (mode == 2 || mode == 3) {
return true
}
}
}
} }
return super.dispatchTouchEvent(ev) return super.dispatchTouchEvent(ev)
} }
@@ -0,0 +1,204 @@
package com.tv.browser
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.util.Log
import android.view.View
import android.webkit.WebView
import android.widget.EditText
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import java.io.File
/**
* 局域网手机遥控与文件快传辅助组件(从 BrowserActivity 拆分):
* 负责 RemoteControlServer 启停、状态圆点刷新、远程输入法文字注入、快传 APK 安装与视频播放弹窗。
*/
class RemoteControlHelper(
private val activity: AppCompatActivity,
private val mainHandler: Handler,
private val callback: RemoteControlCallback
) {
private var server: RemoteControlServer? = null
fun ensureStarted(): Boolean {
val s = server
if (s != null && s.isServerRunning()) return true
try {
server?.stop()
} catch (_: Exception) { }
server = RemoteControlServer(activity, mainHandler, callback).apply { start() }
return server?.isServerRunning() == true
}
fun isRunning(): Boolean = server?.isServerRunning() == true
fun getPort(): Int = server?.port ?: 8766
fun restart(): Boolean {
stop()
return ensureStarted()
}
fun stop() {
try {
server?.stop()
} catch (_: Exception) { }
server = null
}
fun initStatusDot(dot: View) {
dot.background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.RED)
}
}
fun updateStatusDot(dot: View) {
val running = isRunning()
(dot.background as? GradientDrawable)?.setColor(
if (running) Color.parseColor("#4CAF50") else Color.RED
)
}
fun adjustVolume(raise: Boolean) {
val am = activity.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
val dir = if (raise) AudioManager.ADJUST_RAISE else AudioManager.ADJUST_LOWER
am.adjustStreamVolume(AudioManager.STREAM_MUSIC, dir, AudioManager.FLAG_SHOW_UI)
}
fun toggleMute() {
val am = activity.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
am.adjustStreamVolume(AudioManager.STREAM_MUSIC, AudioManager.ADJUST_TOGGLE_MUTE, AudioManager.FLAG_SHOW_UI)
} else {
@Suppress("DEPRECATION")
am.setStreamMute(AudioManager.STREAM_MUSIC, true)
}
}
/** 将手机遥控端发来的文字填入当前焦点输入框、网页 activeElement 或地址栏 */
fun handleRemoteText(
text: String,
currentFocus: View?,
isOnHomePage: Boolean,
webView: WebView,
urlBar: EditText
) {
if (text.isEmpty()) return
val focusedEdit = currentFocus as? EditText
if (focusedEdit != null) {
val editable = focusedEdit.text
editable.replace(0, editable.length, text)
focusedEdit.requestFocus()
focusedEdit.setSelection(text.length)
return
}
if (!isOnHomePage) {
val jsText = text
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "")
val js = """
(function() {
var el = document.activeElement;
if (!el || (!el.isContentEditable && ['INPUT','TEXTAREA'].indexOf(el.tagName) === -1)) {
var candidates = Array.prototype.slice.call(
document.querySelectorAll('input, textarea, [contenteditable=""], [contenteditable="true"]')
);
el = candidates.find(function(f) {
return !f.disabled && f.type !== 'hidden' && f.offsetParent !== null;
});
}
if (!el) return;
var insert = '$jsText';
if (typeof el.value === 'string') el.value = insert;
else el.textContent = insert;
var caret = insert.length;
if (el.setSelectionRange) {
try { el.setSelectionRange(caret, caret); } catch (e) {}
}
if (typeof el.focus === 'function') el.focus();
try {
var evInput = new Event('input', { bubbles: true });
el.dispatchEvent(evInput);
} catch (e) {
try {
var ev = document.createEvent('Event');
ev.initEvent('input', true, true);
el.dispatchEvent(ev);
} catch (e2) {}
}
})();
""".trimIndent()
webView.evaluateJavascript(js, null)
return
}
val editable = urlBar.text
editable.replace(0, editable.length, text)
urlBar.requestFocus()
urlBar.setSelection(text.length)
}
/** 处理手机快传上传的文件(APK 安装包、视频文件或普通文件) */
fun handleFileUploaded(file: File, filename: String) {
val lower = filename.lowercase()
if (lower.endsWith(".apk")) {
promptInstallApk(file, filename)
} else if (lower.endsWith(".mp4") || lower.endsWith(".m3u8") || lower.endsWith(".mkv") || lower.endsWith(".flv")) {
AlertDialog.Builder(activity)
.setTitle("🎬 收到手机快传视频")
.setMessage("文件:$filename\n大小:${file.length() / 1024 / 1024}MB\n是否立即全屏播放?")
.setPositiveButton("立即播放") { _, _ ->
PlayerActivity.start(activity, Uri.fromFile(file).toString(), filename)
}
.setNegativeButton("稍后", null)
.show()
} else {
Toast.makeText(activity, "收到手机文件:$filename (已存入下载目录)", Toast.LENGTH_LONG).show()
}
}
private fun promptInstallApk(file: File, filename: String) {
try {
val uri = FileProvider.getUriForFile(
activity,
"${activity.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
AlertDialog.Builder(activity)
.setTitle("📱 收到手机快传 APK 安装包")
.setMessage("应用文件:$filename\n大小:${file.length() / 1024 / 1024}MB\n是否立即安装?")
.setPositiveButton("立即安装") { _, _ ->
try {
activity.startActivity(intent)
} catch (e: Exception) {
Log.e(TAG, "Failed to launch package installer", e)
Toast.makeText(activity, "打开安装器失败: ${e.message}", Toast.LENGTH_LONG).show()
}
}
.setNegativeButton("稍后", null)
.show()
} catch (e: Exception) {
Log.e(TAG, "FileProvider error for APK", e)
Toast.makeText(activity, "解析安装包异常: ${e.message}", Toast.LENGTH_SHORT).show()
}
}
companion object {
private const val TAG = "TVBrowser.Remote"
}
}
@@ -0,0 +1,286 @@
package com.tv.browser
import android.content.Intent
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.View
import android.webkit.WebView
import android.widget.Button
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
/**
* 视频嗅探状态机与自动全屏倒计时控制器(从 BrowserActivity 拆分):
* 负责维护当前网页嗅探到的视频源列表、请求头回放缓存、站点选集信息、3 秒自动接管倒计时,以及启动原生/外部播放器。
*/
class VideoSnifferController(
private val activity: AppCompatActivity,
private val webView: WebView,
private val btnNativePlay: Button,
private val btnFloatingPlay: Button,
private val isOnHomePageProvider: () -> Boolean,
private val currentPageTitleProvider: () -> String,
private val onSuspendWebMedia: () -> Unit
) {
val sniffedVideoList = mutableListOf<SniffedVideo>()
var currentSniffedVideoUrl: String? = null
private set
var currentSniffedVideoTitle: String? = null
private set
var webOnlyNoticeShown: Boolean = false
private set
val requestHeaderCache = VideoSnifferHelper.RequestHeaderCache()
var currentSiteInfo: SiteInfo? = null
var currentSiteInfoJson: String? = null
private var autoPlayHandledPage: String? = null
private var autoPlayPage: String? = null
private var autoPlayRemaining = 0
private val autoPlayHandler = Handler(Looper.getMainLooper())
private val autoPlayTick = Runnable { onAutoPlayTick() }
val hasAnyVideo: Boolean
get() = sniffedVideoList.isNotEmpty() || !currentSniffedVideoUrl.isNullOrBlank()
/** 切换回首页时清空嗅探状态 */
fun clearForHomePage() {
cancelAutoPlay(markHandled = false)
currentSniffedVideoUrl = null
currentSniffedVideoTitle = null
sniffedVideoList.clear()
btnNativePlay.visibility = View.GONE
btnFloatingPlay.visibility = View.GONE
}
/** 新页面开始加载时重置嗅探状态 */
fun resetForNewPage() {
cancelAutoPlay(markHandled = false)
currentSniffedVideoUrl = null
currentSniffedVideoTitle = null
sniffedVideoList.clear()
webOnlyNoticeShown = false
currentSiteInfo = null
currentSiteInfoJson = null
btnNativePlay.visibility = View.GONE
btnFloatingPlay.visibility = View.GONE
}
/** 处理嗅探到的视频流(主线程调用) */
fun handleVideoSniffed(url: String, title: String, kind: String, frameUrl: String?) {
if (isOnHomePageProvider()) return
val trimmed = url.trim()
if (trimmed.isEmpty()) return
// 央视网等加密流:原生播放会花屏,留在网页里播放
if (VideoSnifferHelper.isWebOnlyStream(trimmed)) {
Log.d(TAG, "Web-only stream, skip native: $trimmed")
if (!webOnlyNoticeShown) {
webOnlyNoticeShown = true
Toast.makeText(activity, VideoSnifferHelper.WEB_ONLY_MESSAGE, Toast.LENGTH_LONG).show()
}
return
}
val isHls = VideoSnifferHelper.isHls(trimmed, kind)
if (!isHls && !VideoSnifferHelper.isVideoStreamUrl(trimmed)) return
if (AdBlockHelper.isEnabled(activity) && AdBlockHelper.shouldBlockUrl(trimmed)) return
if (sniffedVideoList.any { it.url == trimmed || VideoSnifferHelper.isLikelyVariantOf(trimmed, it.url) }) return
val effectiveTitle = title.ifBlank { currentPageTitleProvider().ifBlank { "在线影视" } }
val streamType = when {
isHls -> "HLS 极速流"
trimmed.contains(".mp4", ignoreCase = true) -> "MP4 标清/高清"
else -> "通用视频流"
}
sniffedVideoList.add(SniffedVideo(trimmed, effectiveTitle, streamType, kind, frameUrl))
currentSniffedVideoUrl = trimmed
currentSniffedVideoTitle = effectiveTitle
Log.d(TAG, "Video sniffed[$kind]: $trimmed (total streams=${sniffedVideoList.size})")
btnNativePlay.visibility = View.VISIBLE
btnFloatingPlay.visibility = View.VISIBLE
updatePlayButtonsText()
if (currentSiteInfo == null) {
webView.evaluateJavascript(VideoSnifferHelper.JS_SITE_ADAPTER_MACCMS, null)
}
if (isHls) maybeStartAutoPlay()
}
fun updatePlayButtonsText() {
if (autoPlayRemaining > 0) {
btnFloatingPlay.text = "🎬 ${autoPlayRemaining} 秒后全屏播放 · 按返回取消"
} else if (sniffedVideoList.size > 1) {
btnFloatingPlay.text = "🎬 检测到 ${sniffedVideoList.size} 条视频线路 · 点此选线播放"
} else {
btnFloatingPlay.text = "🎬 检测到视频源 · 点此原生播放"
}
btnNativePlay.text = if (sniffedVideoList.size > 1) "🎬 线路(${sniffedVideoList.size})" else "🎬 原生播放"
}
private fun currentPageKey(): String? =
webView.url?.takeIf { it.startsWith("http") }?.let { SiteInfo.normalizeUrl(it) }
private fun maybeStartAutoPlay() {
if (!AppSettings.isAutoTakeover(activity) || isOnHomePageProvider() || autoPlayRemaining > 0) return
if (!activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return
val page = currentPageKey() ?: return
if (page == autoPlayHandledPage) return
autoPlayPage = page
autoPlayRemaining = AUTO_PLAY_SECONDS
Log.d(TAG, "auto play countdown start: $page")
updatePlayButtonsText()
autoPlayHandler.removeCallbacks(autoPlayTick)
autoPlayHandler.postDelayed(autoPlayTick, 1000)
}
private fun onAutoPlayTick() {
if (autoPlayRemaining <= 0) return
val page = currentPageKey()
if (isOnHomePageProvider() || page == null || page != autoPlayPage) {
cancelAutoPlay(markHandled = false)
return
}
if (!activity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) {
cancelAutoPlay(markHandled = true)
return
}
autoPlayRemaining--
if (autoPlayRemaining > 0) {
updatePlayButtonsText()
autoPlayHandler.postDelayed(autoPlayTick, 1000)
return
}
autoPlayHandledPage = page
updatePlayButtonsText()
val video = sniffedVideoList.firstOrNull { VideoSnifferHelper.isHls(it.url, it.kind) }
?: sniffedVideoList.firstOrNull()
if (video != null) {
Log.d(TAG, "auto play: ${video.url}")
startNativePlayer(video)
}
}
/** 取消自动播放倒计时;[markHandled] 为 true 时本页不再自动播放 */
fun cancelAutoPlay(markHandled: Boolean): Boolean {
if (autoPlayRemaining <= 0) return false
autoPlayHandler.removeCallbacks(autoPlayTick)
autoPlayRemaining = 0
if (markHandled) autoPlayHandledPage = autoPlayPage
autoPlayPage = null
updatePlayButtonsText()
return true
}
private fun buildVideoSource(video: SniffedVideo): VideoSource {
val pageUrl = webView.url?.takeIf { it.startsWith("http") }
val headers = VideoSnifferHelper.buildPlaybackHeaders(
url = video.url,
captured = requestHeaderCache.get(video.url),
userAgent = webView.settings.userAgentString ?: BrowserActivity.currentUserAgent(activity),
fallbackReferer = video.frameUrl ?: pageUrl
)
val info = currentSiteInfo
val title = info?.displayTitle()?.takeIf { it.isNotBlank() } ?: video.title
return VideoSource(
url = video.url,
title = title,
pageUrl = pageUrl,
headers = headers,
isHls = VideoSnifferHelper.isHls(video.url, video.kind)
)
}
private fun siteInfoForCurrentPage(): SiteInfo? {
val info = currentSiteInfo ?: return null
val page = webView.url ?: return null
return if (SiteInfo.normalizeUrl(info.current) == SiteInfo.normalizeUrl(page)) info else null
}
private fun startNativePlayer(video: SniffedVideo) {
cancelAutoPlay(markHandled = true)
currentPageKey()?.let { autoPlayHandledPage = it }
onSuspendWebMedia()
val source = buildVideoSource(video)
Log.d(TAG, "launch native: ${source.url} page=${source.pageUrl} headers=${source.headers.keys}")
PlayerActivity.start(activity, source, siteInfoForCurrentPage())
}
private fun noVideoMessage(): String =
if (webOnlyNoticeShown) VideoSnifferHelper.WEB_ONLY_MESSAGE else "暂未嗅探到可播放的视频源"
/** 启动原生全屏播放器:多线路时弹出选择对话框 */
fun launchNativePlayer() {
if (sniffedVideoList.isEmpty()) {
val url = currentSniffedVideoUrl
if (url.isNullOrBlank()) {
Toast.makeText(activity, noVideoMessage(), Toast.LENGTH_SHORT).show()
return
}
startNativePlayer(SniffedVideo(url, currentSniffedVideoTitle ?: currentPageTitleProvider(), ""))
return
}
val hlsList = sniffedVideoList.filter { VideoSnifferHelper.isHls(it.url, it.kind) }
if (sniffedVideoList.size == 1 || hlsList.size == 1) {
startNativePlayer(hlsList.firstOrNull() ?: sniffedVideoList[0])
return
}
val items = sniffedVideoList.mapIndexed { idx, s -> "线路 ${idx + 1} [${s.type}]" }.toTypedArray()
AlertDialog.Builder(activity)
.setTitle("选择播放线路 (${sniffedVideoList.size} 条可用)")
.setItems(items) { _, which -> startNativePlayer(sniffedVideoList[which]) }
.setNegativeButton("取消", null)
.show()
}
/** 调用系统外部已安装的专业播放器(Kodi / MX Player / VLC 等) */
fun launchExternalPlayer() {
if (sniffedVideoList.isEmpty()) {
val url = currentSniffedVideoUrl
if (url.isNullOrBlank()) {
Toast.makeText(activity, noVideoMessage(), Toast.LENGTH_SHORT).show()
return
}
startExternalPlayerIntent(SniffedVideo(url, currentSniffedVideoTitle ?: currentPageTitleProvider(), ""))
return
}
if (sniffedVideoList.size == 1) {
startExternalPlayerIntent(sniffedVideoList[0])
return
}
val items = sniffedVideoList.mapIndexed { idx, s -> "线路 ${idx + 1} [${s.type}]" }.toTypedArray()
AlertDialog.Builder(activity)
.setTitle("选择调用外部播放器的线路")
.setItems(items) { _, which -> startExternalPlayerIntent(sniffedVideoList[which]) }
.setNegativeButton("取消", null)
.show()
}
private fun startExternalPlayerIntent(video: SniffedVideo) {
val source = buildVideoSource(video)
try {
onSuspendWebMedia()
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(Uri.parse(source.url), if (source.isHls) "application/x-mpegURL" else "video/*")
putExtra("title", source.title)
if (source.headers.isNotEmpty()) {
putExtra("headers", source.headers.flatMap { listOf(it.key, it.value) }.toTypedArray())
}
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
activity.startActivity(Intent.createChooser(intent, "选择外部播放器"))
} catch (e: Exception) {
Log.e(TAG, "External player launch failed", e)
Toast.makeText(activity, "未找到外部播放器,切换内置原生播放", Toast.LENGTH_SHORT).show()
PlayerActivity.start(activity, source, siteInfoForCurrentPage())
}
}
companion object {
private const val TAG = "TVBrowser.Video"
private const val AUTO_PLAY_SECONDS = 3
}
}
@@ -232,7 +232,7 @@ object VideoSnifferHelper {
* 同时监听 {__tvcmd:'pause'} / {__tvcmd:'resume'} 消息,供原生层暂停网页内所有视频并停止 / 恢复 hls.js 缓冲(含 iframe)。 * 同时监听 {__tvcmd:'pause'} / {__tvcmd:'resume'} 消息,供原生层暂停网页内所有视频并停止 / 恢复 hls.js 缓冲(含 iframe)。
*/ */
val JS_DOCUMENT_START_HOOK = """ val JS_DOCUMENT_START_HOOK = """
(function() { (function __tvHook() {
if (typeof window.artplayerPluginDanmaku !== 'function') { if (typeof window.artplayerPluginDanmaku !== 'function') {
window.artplayerPluginDanmaku = function() { window.artplayerPluginDanmaku = function() {
return function() { return function() {
@@ -424,6 +424,33 @@ object VideoSnifferHelper {
} catch (_) {} } catch (_) {}
}); });
} catch (e) {} } catch (e) {}
// 旧版 WebView(不支持 DOCUMENT_START_SCRIPT)兜底:将 Hook 自动传递到可访问的子 iframe
try {
function injectChildFrames() {
var fs = document.getElementsByTagName('iframe');
for (var i = 0; i < fs.length; i++) {
try {
var f = fs[i];
var cw = f.contentWindow;
if (cw && !cw.__tvSniffInstalled) {
cw.eval('(' + __tvHook.toString() + ')()');
}
if (!f.__tvHookBound) {
f.__tvHookBound = true;
f.addEventListener('load', function() {
try {
var w = this.contentWindow;
if (w && !w.__tvSniffInstalled) w.eval('(' + __tvHook.toString() + ')()');
} catch (_) {}
});
}
} catch (_) {}
}
}
injectChildFrames();
document.addEventListener('DOMContentLoaded', injectChildFrames);
window.addEventListener('load', injectChildFrames);
} catch (e) {}
})(); })();
""".trimIndent() """.trimIndent()
@@ -0,0 +1,138 @@
package com.tv.browser
import android.content.Context
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
import android.widget.EditText
import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
/**
* 跨站聚合搜片弹窗组件(VodSearchDialog):
* 独立承载「输入片名弹窗」与「多站点实时并发搜索结果列表弹窗」,为 BrowserActivity 减负。
*/
typealias VodSearchDialog = VodSearchDialogHelper
object VodSearchDialogHelper {
fun showInputDialog(
context: Context,
prefill: String,
onSearch: (String) -> Unit
) {
val input = EditText(context).apply {
setText(prefill)
setSelection(text.length)
hint = "输入片名,如:沧元图"
isSingleLine = true
imeOptions = EditorInfo.IME_ACTION_SEARCH
}
val pad = (20 * context.resources.displayMetrics.density).toInt()
val box = FrameLayout(context).apply {
setPadding(pad, pad / 2, pad, 0)
addView(input)
}
val dialog = AlertDialog.Builder(context)
.setTitle("全网搜片(在收藏的影视站里搜)")
.setView(box)
.setPositiveButton("搜索") { _, _ ->
input.text.toString().trim().takeIf { it.isNotEmpty() }?.let(onSearch)
}
.setNegativeButton(context.getString(R.string.close), null)
.create()
input.setOnEditorActionListener { _, _, _ ->
val kw = input.text.toString().trim()
if (kw.isNotEmpty()) {
dialog.dismiss()
onSearch(kw)
}
true
}
dialog.show()
input.requestFocus()
}
fun showResultsDialog(
context: Context,
keyword: String,
helper: VodSearchHelper,
bookmarks: List<Bookmark>,
userAgent: String,
onOpenUrl: (String) -> Unit
) {
val sites = helper.collectSites(bookmarks)
val webEngine = SearchEngineHelper.getWebEngine(context)
if (sites.isEmpty()) {
Toast.makeText(context, "收藏里还没有影视站,先用${webEngine.name}搜索", Toast.LENGTH_SHORT).show()
onOpenUrl(SearchEngineHelper.buildSearchUrl(webEngine, keyword))
return
}
val allResults = mutableListOf<VodResult>()
val labels = mutableListOf<String>()
val webLabel = "🌐 用${webEngine.name}搜索「$keyword」"
val pad = (20 * context.resources.displayMetrics.density).toInt()
val status = TextView(context).apply {
text = "正在 ${sites.size} 个收藏站点里搜索「$keyword」…"
textSize = 15f
setPadding(pad, pad / 2, pad, pad / 2)
}
val adapter = ArrayAdapter(context, android.R.layout.simple_list_item_1, labels)
val list = ListView(context).apply { this.adapter = adapter }
val root = LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
addView(status)
addView(
list,
LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
(context.resources.displayMetrics.heightPixels * 0.55f).toInt()
)
)
}
fun refresh() {
labels.clear()
allResults.forEach { labels.add("${it.name}  · ${it.siteName}") }
labels.add(webLabel)
adapter.notifyDataSetChanged()
}
refresh()
val dialog = AlertDialog.Builder(context)
.setTitle("搜片:$keyword")
.setView(root)
.setNegativeButton(context.getString(R.string.close), null)
.create()
list.setOnItemClickListener { _, _, position, _ ->
dialog.dismiss()
val r = allResults.getOrNull(position)
onOpenUrl(r?.detailUrl ?: SearchEngineHelper.buildSearchUrl(webEngine, keyword))
}
dialog.setOnDismissListener { helper.cancel() }
dialog.show()
list.requestFocus()
helper.search(keyword, sites, userAgent, object : VodSearchHelper.Listener {
override fun onSiteDone(site: String, results: List<VodResult>, done: Int, total: Int) {
if (!dialog.isShowing) return
if (results.isNotEmpty()) {
val merged = (allResults + results).sortedBy { VodSearchHelper.score(it.name, keyword) }
allResults.clear()
allResults.addAll(merged)
refresh()
}
val siteHits = allResults.map { it.host }.distinct().size
status.text = when {
done < total -> "正在搜索…($done/$total 个站点完成)已找到 ${allResults.size} 条"
allResults.isEmpty() -> "收藏的 $total 个站点里没找到「$keyword」,可用下方网页搜索"
else -> "搜索完成:$siteHits 个站点共 ${allResults.size} 条,选一个打开详情页"
}
}
})
}
}
@@ -10,8 +10,15 @@ import org.json.JSONObject
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.net.URLEncoder import java.net.URLEncoder
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.util.concurrent.ExecutorService import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors import java.util.concurrent.Executors
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager
/** 一条聚合搜索结果 */ /** 一条聚合搜索结果 */
data class VodResult( data class VodResult(
@@ -24,13 +31,9 @@ data class VodResult(
/** /**
* 跨站聚合搜片:在用户收藏的影视站里同时搜索同一个片名。 * 跨站聚合搜片:在用户收藏的影视站里同时搜索同一个片名。
* *
* 绝大多数盗版影视站都基于苹果 CMS(MacCMS),它的搜索页通常要验证码, * 1. 苹果 CMS(MacCMS)联想接口 `/index.php/ajax/suggest?mid=1&wd=关键词`,无需验证码直接返回 JSON。
* 但联想接口 `/index.php/ajax/suggest?mid=1&wd=关键词` 不需要验证码,返回 JSON: * 2. WordPress / 现代影视站(如 4K影视 4kvm.tv、厂长资源 czzy77.com 等)HTML 搜索页兜底解析(`/?s=关键词`、`/xssearch?q=关键词`)。
* `{code:1, list:[{id, name, en, pic}]}`。拿到 id 后再拼出详情页地址。 * 3. 对最近一次搜索词缓存 2 分钟结果,重复查看秒开。
*
* 详情页地址各站伪静态规则不同(/voddetail/123.html、/detail/123.html …),
* 首次搜索某站时抓一次首页,找出一个详情页链接推断模板并缓存;找不到时用 MacCMS 动态路由。
* 非苹果 CMS 站点接口不存在 / 返回非 JSON,直接忽略。
*/ */
class VodSearchHelper(context: Context) { class VodSearchHelper(context: Context) {
@@ -42,7 +45,7 @@ class VodSearchHelper(context: Context) {
private val appContext = context.applicationContext private val appContext = context.applicationContext
private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var executor: ExecutorService? = null private val runningFutures = ArrayList<java.util.concurrent.Future<*>>()
@Volatile private var generation = 0 @Volatile private var generation = 0
/** 收藏里去重后的站点(按域名),返回 (origin, 站点名) */ /** 收藏里去重后的站点(按域名),返回 (origin, 站点名) */
@@ -62,65 +65,145 @@ class VodSearchHelper(context: Context) {
fun search(keyword: String, sites: List<Pair<String, String>>, userAgent: String, listener: Listener) { fun search(keyword: String, sites: List<Pair<String, String>>, userAgent: String, listener: Listener) {
cancel() cancel()
val trimmedKw = keyword.trim()
val cached = getCachedSearch(trimmedKw, sites.size)
if (cached != null) {
mainHandler.post {
listener.onSiteDone("缓存", cached, sites.size, sites.size)
}
return
}
val gen = ++generation val gen = ++generation
val pool = Executors.newFixedThreadPool(THREADS).also { executor = it }
val total = sites.size val total = sites.size
var done = 0 var done = 0
val aggregated = ArrayList<VodResult>()
val cookies = CookieManager.getInstance() val cookies = CookieManager.getInstance()
for ((origin, siteName) in sites) { synchronized(runningFutures) {
val cookie = runCatching { cookies.getCookie(origin) }.getOrNull() runningFutures.clear()
pool.execute { for ((origin, siteName) in sites) {
val results = runCatching { searchSite(origin, siteName, keyword, userAgent, cookie) } val cookie = runCatching { cookies.getCookie(origin) }.getOrNull()
.onFailure { Log.d(TAG, "$origin failed: ${it.message}") } val future = sharedExecutor.submit {
.getOrDefault(emptyList()) if (gen != generation) return@submit
mainHandler.post { val results = runCatching { searchSite(origin, siteName, trimmedKw, userAgent, cookie) }
if (gen != generation) return@post .onFailure { Log.d(TAG, "$origin failed: ${it.message}") }
done++ .getOrDefault(emptyList())
listener.onSiteDone(siteName, results, done, total) mainHandler.post {
if (gen != generation) return@post
done++
if (results.isNotEmpty()) aggregated.addAll(results)
if (done == total && aggregated.isNotEmpty()) {
putCachedSearch(trimmedKw, total, aggregated.toList())
}
listener.onSiteDone(siteName, results, done, total)
}
} }
runningFutures.add(future)
} }
} }
pool.shutdown()
} }
fun cancel() { fun cancel() {
generation++ generation++
executor?.shutdownNow() synchronized(runningFutures) {
executor = null runningFutures.forEach { it.cancel(true) }
runningFutures.clear()
}
} }
private fun searchSite( private fun searchSite(
origin: String, siteName: String, keyword: String, ua: String, cookie: String? origin: String, siteName: String, keyword: String, ua: String, cookie: String?
): List<VodResult> { ): List<VodResult> {
val kw = URLEncoder.encode(keyword, "UTF-8") 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 host = Uri.parse(origin).host.orEmpty()
val sample = list.optJSONObject(0)
val template = detailTemplate( // 1. 尝试 MacCMS 标准 Ajax JSON 接口
origin, host, ua, cookie, val body = httpGet("$origin/index.php/ajax/suggest?mid=1&wd=$kw&limit=30", ua, cookie, origin)
sample?.optString("id").orEmpty(), sample?.optString("name").orEmpty().trim() if (body != null) {
) val json = runCatching { JSONObject(body.trim()) }.getOrNull()
val out = ArrayList<VodResult>() val list = json?.optJSONArray("list")
for (i in 0 until list.length()) { if (list != null && list.length() > 0) {
val o = list.optJSONObject(i) ?: continue val sample = list.optJSONObject(0)
val id = o.optString("id").ifBlank { o.optString("vod_id") } val template = detailTemplate(
val name = o.optString("name").ifBlank { o.optString("vod_name") }.trim() origin, host, ua, cookie,
if (id.isBlank() || name.isBlank()) continue sample?.optString("id").orEmpty(), sample?.optString("name").orEmpty().trim()
val path = template.replace("{id}", id) )
out.add(VodResult(name, siteName, host, if (path.startsWith("http")) path else origin + path)) 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))
}
if (out.isNotEmpty()) {
Log.d(TAG, "$origin -> ${out.size} results (maccms tpl=$template)")
return out
}
}
} }
Log.d(TAG, "$origin -> ${out.size} results (tpl=$template)")
return out // 2. 兜底:WordPress / 4kvm / 厂长资源等站点的 HTML 搜索页解析
return searchHtmlFallback(origin, host, siteName, keyword, kw, ua, cookie)
}
/**
* 针对 4kvm.tv(/?s=关键词)、厂长资源(/xssearch?q=关键词 或 /?s=关键词)及通用影视站的 HTML 搜索结果提取
*/
private fun searchHtmlFallback(
origin: String,
host: String,
siteName: String,
rawKeyword: String,
encodedKw: String,
ua: String,
cookie: String?
): List<VodResult> {
val searchPaths = if (host.contains("czzy") || host.contains("4kcz")) {
listOf("/xssearch?q=$encodedKw", "/?s=$encodedKw")
} else {
listOf("/?s=$encodedKw", "/vodsearch/-------------.html?wd=$encodedKw")
}
val out = ArrayList<VodResult>()
val seenUrls = HashSet<String>()
val kwLower = rawKeyword.lowercase()
for (searchPath in searchPaths) {
val html = runCatching { httpGet(origin + searchPath, ua, cookie, origin, ajax = false) }.getOrNull()
?: continue
for (m in HTML_RESULT_LINK.findAll(html)) {
var href = m.groupValues[1].trim()
val rawTitle = m.groupValues[2]
.replace(HTML_TAG_STRIP, "")
.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&#8211;", "-")
.trim()
if (rawTitle.length < 2 || rawTitle.length > 60) continue
if (BAD_LINK_TEXT.containsMatchIn(rawTitle)) continue
if (!rawTitle.lowercase().contains(kwLower) && kwLower.length >= 2) continue
if (href.startsWith("//")) href = Uri.parse(origin).scheme + ":" + href
else if (href.startsWith("/")) href = origin + href
if (!href.startsWith("http")) continue
val u = runCatching { Uri.parse(href) }.getOrNull() ?: continue
if (u.host != null && u.host != host) continue
if (!seenUrls.add(href)) continue
out.add(VodResult(rawTitle, siteName, host, href))
if (out.size >= 15) break
}
if (out.isNotEmpty()) {
Log.d(TAG, "$origin -> ${out.size} results (html $searchPath)")
return out
}
}
return emptyList()
} }
/** /**
* 推断详情页地址模板,如 `/voddetail/{id}.html`;结果按域名缓存。 * 推断详情页地址模板,如 `/voddetail/{id}.html`;结果按域名缓存。
* 候选:首页里找到的本站详情链接 + 常见伪静态规则,逐个用 [sampleId] 实际请求验证
* (返回 2xx 且页面里出现片名 [sampleName] 才算对)。
*/ */
private fun detailTemplate( private fun detailTemplate(
origin: String, host: String, ua: String, cookie: String?, sampleId: String, sampleName: String origin: String, host: String, ua: String, cookie: String?, sampleId: String, sampleName: String
@@ -129,7 +212,6 @@ class VodSearchHelper(context: Context) {
val candidates = LinkedHashSet<String>() val candidates = LinkedHashSet<String>()
val html = runCatching { httpGet("$origin/", ua, cookie, origin, ajax = false) }.getOrNull() val html = runCatching { httpGet("$origin/", ua, cookie, origin, ajax = false) }.getOrNull()
if (html != null) { if (html != null) {
// 第一个匹配可能是广告外链,逐个找本站链接
for (m in DETAIL_HREF.findAll(html)) { for (m in DETAIL_HREF.findAll(html)) {
var href = m.groupValues[1] var href = m.groupValues[1]
if (href.startsWith("//")) href = Uri.parse(origin).scheme + ":" + href if (href.startsWith("//")) href = Uri.parse(origin).scheme + ":" + href
@@ -156,13 +238,16 @@ class VodSearchHelper(context: Context) {
return tpl return tpl
} }
} }
// 都没验证通过(可能是网络问题),不缓存,下次再试
return candidates.first() return candidates.first()
} }
private fun httpGet(url: String, ua: String, cookie: String?, referer: String, ajax: Boolean = true): String? { private fun httpGet(url: String, ua: String, cookie: String?, referer: String, ajax: Boolean = true): String? {
val conn = URL(url).openConnection() as HttpURLConnection val conn = URL(url).openConnection() as HttpURLConnection
try { try {
if (conn is HttpsURLConnection) {
conn.sslSocketFactory = lenientSslSocketFactory
conn.hostnameVerifier = lenientHostnameVerifier
}
conn.connectTimeout = TIMEOUT_MS conn.connectTimeout = TIMEOUT_MS
conn.readTimeout = TIMEOUT_MS conn.readTimeout = TIMEOUT_MS
conn.instanceFollowRedirects = true conn.instanceFollowRedirects = true
@@ -173,7 +258,6 @@ class VodSearchHelper(context: Context) {
if (!cookie.isNullOrBlank()) conn.setRequestProperty("Cookie", cookie) if (!cookie.isNullOrBlank()) conn.setRequestProperty("Cookie", cookie)
if (conn.responseCode !in 200..299) return null if (conn.responseCode !in 200..299) return null
val bytes = conn.inputStream.use { input -> val bytes = conn.inputStream.use { input ->
// 首页可能很大,最多读 1MB
val buf = java.io.ByteArrayOutputStream() val buf = java.io.ByteArrayOutputStream()
val chunk = ByteArray(16 * 1024) val chunk = ByteArray(16 * 1024)
while (buf.size() < MAX_BYTES) { while (buf.size() < MAX_BYTES) {
@@ -195,6 +279,46 @@ class VodSearchHelper(context: Context) {
private const val THREADS = 4 private const val THREADS = 4
private const val TIMEOUT_MS = 6000 private const val TIMEOUT_MS = 6000
private const val MAX_BYTES = 1024 * 1024 private const val MAX_BYTES = 1024 * 1024
private const val CACHE_TTL_MS = 120_000L
@Volatile private var lastSearchKey: String = ""
@Volatile private var lastSearchTime: Long = 0L
@Volatile private var lastSearchResults: List<VodResult> = emptyList()
private fun getCachedSearch(keyword: String, siteCount: Int): List<VodResult>? {
val key = "${keyword.lowercase()}|$siteCount"
if (key == lastSearchKey &&
lastSearchResults.isNotEmpty() &&
System.currentTimeMillis() - lastSearchTime < CACHE_TTL_MS
) {
return lastSearchResults
}
return null
}
private fun putCachedSearch(keyword: String, siteCount: Int, results: List<VodResult>) {
lastSearchKey = "${keyword.lowercase()}|$siteCount"
lastSearchTime = System.currentTimeMillis()
lastSearchResults = results
}
private val lenientHostnameVerifier = HostnameVerifier { _, _ -> true }
private val sharedExecutor: ExecutorService by lazy {
Executors.newFixedThreadPool(THREADS) { r ->
Thread(r, "VodSearchWorker").apply { isDaemon = true }
}
}
private val lenientSslSocketFactory by lazy {
val trustAll = arrayOf<TrustManager>(
object : X509TrustManager {
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
override fun checkClientTrusted(chain: Array<X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<X509Certificate>?, authType: String?) {}
}
)
SSLContext.getInstance("TLS").apply { init(null, trustAll, SecureRandom()) }.socketFactory
}
/** 常见的苹果 CMS 详情页伪静态规则(最后一个是动态路由,伪静态未开时也能用) */ /** 常见的苹果 CMS 详情页伪静态规则(最后一个是动态路由,伪静态未开时也能用) */
private val COMMON_TEMPLATES = listOf( private val COMMON_TEMPLATES = listOf(
"/voddetail/{id}.html", "/voddetail/{id}.html",
@@ -204,7 +328,16 @@ class VodSearchHelper(context: Context) {
) )
private val DETAIL_HREF = private val DETAIL_HREF =
Regex("href=[\"']([^\"'\\s]*?detail[^\"'\\s]*?\\d+[^\"'\\s]*?)[\"']", RegexOption.IGNORE_CASE) Regex("href=[\"']([^\"'\\s]*?detail[^\"'\\s]*?\\d+[^\"'\\s]*?)[\"']", RegexOption.IGNORE_CASE)
/** 明显不是苹果 CMS 的大站,不浪费请求 */
/** 匹配 4kvm (/movies/..., /tvshows/...)、厂长 (/movie/...)、通用详情链接 */
private val HTML_RESULT_LINK = Regex(
"<a[^>]+href=[\"']([^\"'\\s]*(?:/movies/|/tvshows/|/movie/|/voddetail/|/detail/|/v_play/)[^\"'\\s]*)[\"'][^>]*>([\\s\\S]*?)</a>",
RegexOption.IGNORE_CASE
)
private val HTML_TAG_STRIP = Regex("<[^>]+>")
private val BAD_LINK_TEXT = Regex("^(首页|电影|电视剧|动漫|综艺|美剧|韩剧|分类|排行|全部|更多|播放|立即播放|查看详情)$")
/** 明显不是影视资源站的通用大站,不浪费请求 */
private val SKIP_HOSTS = setOf( private val SKIP_HOSTS = setOf(
"baidu.com", "bing.com", "google.com", "sogou.com", "so.com", "bilibili.com", "baidu.com", "bing.com", "google.com", "sogou.com", "so.com", "bilibili.com",
"iqiyi.com", "youku.com", "qq.com", "mgtv.com", "douban.com", "youtube.com", "iqiyi.com", "youku.com", "qq.com", "mgtv.com", "douban.com", "youtube.com",