4k
This commit is contained in:
@@ -17,7 +17,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
|
||||
Vendored
+15
@@ -1,6 +1,17 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
|
||||
# Keep attributes required for WebView JavascriptInterface & reflection
|
||||
-keepattributes JavascriptInterface
|
||||
-keepattributes *Annotation*
|
||||
-keepattributes Signature, InnerClasses, EnclosingMethod
|
||||
|
||||
# Keep all @JavascriptInterface methods across the app
|
||||
-keepclassmembers class com.tv.browser.** {
|
||||
@android.webkit.JavascriptInterface <methods>;
|
||||
}
|
||||
|
||||
# Keep WebView related classes
|
||||
-keepclassmembers class * extends android.webkit.WebViewClient {
|
||||
public void *(android.webkit.WebView, java.lang.String);
|
||||
@@ -9,3 +20,7 @@
|
||||
-keepclassmembers class * extends android.webkit.WebChromeClient {
|
||||
public void *(android.webkit.WebView, java.lang.String);
|
||||
}
|
||||
|
||||
# NanoHTTPD
|
||||
-dontwarn fi.iki.elonen.**
|
||||
-keep class fi.iki.elonen.** { *; }
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.tv.browser
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.webkit.WebResourceResponse
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
@@ -15,9 +14,10 @@ import java.io.ByteArrayInputStream
|
||||
*/
|
||||
object AdBlockHelper {
|
||||
|
||||
private const val TAG = "TVBrowser.AdBlock"
|
||||
private const val PREFS_NAME = "tv_browser_adblock"
|
||||
private const val KEY_ADBLOCK_ENABLED = "adblock_enabled"
|
||||
@Volatile
|
||||
private var cachedEnabled: Boolean? = null
|
||||
|
||||
/** 常见广告与跟踪域名黑名单关键词 */
|
||||
private val AD_HOST_PATTERNS = arrayOf(
|
||||
@@ -62,14 +62,16 @@ object AdBlockHelper {
|
||||
"intent"
|
||||
)
|
||||
|
||||
/** 是否开启去广告(默认开启) */
|
||||
/** 是否开启去广告(默认开启,内存缓存免除 shouldInterceptRequest 高频读取 SharedPreferences) */
|
||||
fun isEnabled(context: Context): Boolean {
|
||||
cachedEnabled?.let { return it }
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
return prefs.getBoolean(KEY_ADBLOCK_ENABLED, true)
|
||||
return prefs.getBoolean(KEY_ADBLOCK_ENABLED, true).also { cachedEnabled = it }
|
||||
}
|
||||
|
||||
/** 设置去广告开关 */
|
||||
fun setEnabled(context: Context, enabled: Boolean) {
|
||||
cachedEnabled = enabled
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
prefs.edit().putBoolean(KEY_ADBLOCK_ENABLED, enabled).apply()
|
||||
}
|
||||
@@ -107,7 +109,7 @@ object AdBlockHelper {
|
||||
* 强力元素隐藏与弹窗防劫持注入脚本:
|
||||
* 1. 隐藏高层级遮罩、悬浮关闭广告、通用横幅容器
|
||||
* 2. 覆盖 window.open,防止影视网站静默弹窗跳出
|
||||
* 3. MutationObserver 监听动态插入的广告节点
|
||||
* 3. MutationObserver 监听动态插入的广告节点(带 250ms 节流防抖,避免高频 DOM 变动导致低配电视重排卡顿)
|
||||
*/
|
||||
val JS_INJECT_ADBLOCK = """
|
||||
(function() {
|
||||
@@ -138,20 +140,30 @@ object AdBlockHelper {
|
||||
style.appendChild(document.createTextNode(css));
|
||||
(document.head || document.documentElement).appendChild(style);
|
||||
|
||||
// 3. 动态监听并隐藏常见弹层遮罩
|
||||
// 3. 动态监听并隐藏常见弹层遮罩(节流执行)
|
||||
function cleanPopups() {
|
||||
var popups = document.querySelectorAll('div[style*="z-index"], iframe[src*="ad"]');
|
||||
for (var i = 0; i < popups.length; i++) {
|
||||
var el = popups[i];
|
||||
if (el.__tvAdCleaned) continue;
|
||||
var z = parseInt(window.getComputedStyle(el).zIndex) || 0;
|
||||
if (z >= 99999 && !el.querySelector('video')) {
|
||||
el.__tvAdCleaned = true;
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
}
|
||||
}
|
||||
var cleanTimer = null;
|
||||
function scheduleClean() {
|
||||
if (cleanTimer) return;
|
||||
cleanTimer = setTimeout(function() {
|
||||
cleanTimer = null;
|
||||
cleanPopups();
|
||||
}, 250);
|
||||
}
|
||||
cleanPopups();
|
||||
try {
|
||||
new MutationObserver(cleanPopups).observe(document.documentElement || document.body, { childList: true, subtree: true });
|
||||
new MutationObserver(scheduleClean).observe(document.documentElement || document.body, { childList: true, subtree: true });
|
||||
} catch(e) {}
|
||||
} catch(e) {}
|
||||
})();
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
/**
|
||||
* 应用级小设置:
|
||||
* - 自动接管播放(嗅探到视频后倒计时自动进入原生播放器)
|
||||
* - 播放倍速记忆
|
||||
* - 按剧记住的片头 / 片尾时长(跳过片头片尾)
|
||||
*/
|
||||
object AppSettings {
|
||||
@@ -13,6 +14,8 @@ object AppSettings {
|
||||
private const val KEY_DECODER_MODE = "decoder_mode"
|
||||
private const val KEY_PRELOAD_NEXT = "preload_next"
|
||||
private const val KEY_PURIFY_PAGE = "purify_page"
|
||||
private const val KEY_PLAYBACK_SPEED = "playback_speed"
|
||||
private const val KEY_LENIENT_SSL = "lenient_ssl"
|
||||
private const val SKIP_PREFIX = "skip|"
|
||||
|
||||
/** 解码兼容模式 */
|
||||
@@ -53,6 +56,22 @@ object AppSettings {
|
||||
prefs(context).edit().putBoolean(KEY_PURIFY_PAGE, enabled).apply()
|
||||
}
|
||||
|
||||
/** 记忆的播放倍速(默认 1.0x) */
|
||||
fun getPlaybackSpeed(context: Context): Float =
|
||||
prefs(context).getFloat(KEY_PLAYBACK_SPEED, 1.0f).coerceIn(0.5f, 3.0f)
|
||||
|
||||
fun setPlaybackSpeed(context: Context, speed: Float) {
|
||||
prefs(context).edit().putFloat(KEY_PLAYBACK_SPEED, speed.coerceIn(0.5f, 3.0f)).apply()
|
||||
}
|
||||
|
||||
/** 宽松 SSL 兼容模式(默认开启,兼容电视系统时间未同步与老旧根证书;关闭后仅对日期异常放行,严格拦截假证书) */
|
||||
fun isLenientSsl(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_LENIENT_SSL, true)
|
||||
|
||||
fun setLenientSsl(context: Context, enabled: Boolean) {
|
||||
prefs(context).edit().putBoolean(KEY_LENIENT_SSL, enabled).apply()
|
||||
}
|
||||
|
||||
/** 片头片尾设置:introMs = 片头结束位置;outroMs = 片尾长度(距结尾多少毫秒开始算片尾) */
|
||||
data class SkipConfig(val introMs: Long, val outroMs: Long) {
|
||||
val isEmpty: Boolean get() = introMs <= 0 && outroMs <= 0
|
||||
|
||||
@@ -6,7 +6,7 @@ import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 使用 SharedPreferences 持久化收藏列表。
|
||||
* 使用内存缓存 + SharedPreferences 持久化收藏列表。
|
||||
*/
|
||||
class BookmarkRepository(context: Context) {
|
||||
|
||||
@@ -15,18 +15,25 @@ class BookmarkRepository(context: Context) {
|
||||
private val keyInitialized = "bookmarks_initialized"
|
||||
|
||||
fun getAll(): List<Bookmark> {
|
||||
val isInitialized = prefs.getBoolean(keyInitialized, false)
|
||||
val json = prefs.getString(keyBookmarks, null)
|
||||
if (!isInitialized && json == null) {
|
||||
save(DEFAULT_BOOKMARKS)
|
||||
prefs.edit()
|
||||
.putBoolean(keyInitialized, true)
|
||||
.putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION)
|
||||
.apply()
|
||||
return DEFAULT_BOOKMARKS
|
||||
cachedBookmarks?.let { return it }
|
||||
synchronized(Companion) {
|
||||
cachedBookmarks?.let { return it }
|
||||
val isInitialized = prefs.getBoolean(keyInitialized, false)
|
||||
val json = prefs.getString(keyBookmarks, null)
|
||||
if (!isInitialized && json == null) {
|
||||
save(DEFAULT_BOOKMARKS)
|
||||
prefs.edit()
|
||||
.putBoolean(keyInitialized, true)
|
||||
.putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION)
|
||||
.apply()
|
||||
cachedBookmarks = DEFAULT_BOOKMARKS
|
||||
return DEFAULT_BOOKMARKS
|
||||
}
|
||||
val list = parse(json)
|
||||
val migrated = migrateDefaults(list)
|
||||
cachedBookmarks = migrated
|
||||
return migrated
|
||||
}
|
||||
val list = parse(json)
|
||||
return migrateDefaults(list)
|
||||
}
|
||||
|
||||
private fun parse(json: String?): List<Bookmark> {
|
||||
@@ -75,14 +82,18 @@ class BookmarkRepository(context: Context) {
|
||||
fun contains(url: String): Boolean = getAll().any { it.url == url }
|
||||
|
||||
private fun save(list: List<Bookmark>) {
|
||||
val arr = JSONArray()
|
||||
list.forEach { b ->
|
||||
arr.put(JSONObject().apply {
|
||||
put(KEY_URL, b.url)
|
||||
put(KEY_TITLE, b.title)
|
||||
})
|
||||
val snapshot = list.toList()
|
||||
synchronized(Companion) {
|
||||
cachedBookmarks = snapshot
|
||||
val arr = JSONArray()
|
||||
snapshot.forEach { b ->
|
||||
arr.put(JSONObject().apply {
|
||||
put(KEY_URL, b.url)
|
||||
put(KEY_TITLE, b.title)
|
||||
})
|
||||
}
|
||||
prefs.edit().putString(keyBookmarks, arr.toString()).apply()
|
||||
}
|
||||
prefs.edit().putString(keyBookmarks, arr.toString()).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -90,6 +101,8 @@ class BookmarkRepository(context: Context) {
|
||||
private const val KEY_URL = "url"
|
||||
private const val KEY_TITLE = "title"
|
||||
private const val KEY_DEFAULTS_VERSION = "bookmarks_defaults_version"
|
||||
@Volatile
|
||||
private var cachedBookmarks: List<Bookmark>? = null
|
||||
|
||||
/** 默认收藏版本:新增默认站点时 +1,并在 [DEFAULTS_ADDED_BY_VERSION] 登记 */
|
||||
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 =
|
||||
prefs(context).getBoolean(KEY_STRONG_FILTER, true)
|
||||
fun isStrongFilterEnabled(context: Context): Boolean {
|
||||
cachedStrongFilter?.let { return it }
|
||||
val enabled = prefs(context).getBoolean(KEY_STRONG_FILTER, true)
|
||||
cachedStrongFilter = enabled
|
||||
return enabled
|
||||
}
|
||||
|
||||
fun setStrongFilterEnabled(context: Context, enabled: Boolean) {
|
||||
cachedStrongFilter = enabled
|
||||
prefs(context).edit().putBoolean(KEY_STRONG_FILTER, enabled).apply()
|
||||
}
|
||||
|
||||
/** 无图模式(默认关闭) */
|
||||
fun isNoImageMode(context: Context): Boolean =
|
||||
prefs(context).getBoolean(KEY_NO_IMAGE, false)
|
||||
fun isNoImageMode(context: Context): Boolean {
|
||||
cachedNoImage?.let { return it }
|
||||
val enabled = prefs(context).getBoolean(KEY_NO_IMAGE, false)
|
||||
cachedNoImage = enabled
|
||||
return enabled
|
||||
}
|
||||
|
||||
fun setNoImageMode(context: Context, enabled: Boolean) {
|
||||
cachedNoImage = enabled
|
||||
prefs(context).edit().putBoolean(KEY_NO_IMAGE, enabled).apply()
|
||||
}
|
||||
|
||||
/** 站点是否在白名单(不过滤)中,按主域判断 */
|
||||
fun isWhitelisted(context: Context, host: String?): Boolean {
|
||||
val domain = registrableDomain(host) ?: return false
|
||||
return prefs(context).getStringSet(KEY_WHITELIST, emptySet())?.contains(domain) == true
|
||||
val list = cachedWhitelist ?: prefs(context).getStringSet(KEY_WHITELIST, emptySet()).orEmpty().toSet().also {
|
||||
cachedWhitelist = it
|
||||
}
|
||||
return list.contains(domain)
|
||||
}
|
||||
|
||||
fun setWhitelisted(context: Context, host: String?, whitelisted: Boolean) {
|
||||
val domain = registrableDomain(host) ?: return
|
||||
val set = prefs(context).getStringSet(KEY_WHITELIST, emptySet()).orEmpty().toMutableSet()
|
||||
val set = (cachedWhitelist ?: prefs(context).getStringSet(KEY_WHITELIST, emptySet()).orEmpty()).toMutableSet()
|
||||
if (whitelisted) set.add(domain) else set.remove(domain)
|
||||
cachedWhitelist = set.toSet()
|
||||
prefs(context).edit().putStringSet(KEY_WHITELIST, set).apply()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
* 浏览历史持久化,最多保留 [MAX_ENTRIES] 条。
|
||||
* 浏览历史持久化(内存缓存 + SharedPreferences 异步落盘),最多保留 [MAX_ENTRIES] 条。
|
||||
*/
|
||||
class HistoryRepository(context: Context) {
|
||||
|
||||
@@ -14,20 +14,29 @@ class HistoryRepository(context: Context) {
|
||||
private val keyHistory = "history"
|
||||
|
||||
fun getAll(): List<HistoryItem> {
|
||||
val json = prefs.getString(keyHistory, null)
|
||||
if (json.isNullOrBlank()) return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(json)
|
||||
List(arr.length()) { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
HistoryItem(
|
||||
url = obj.getString(KEY_URL),
|
||||
title = obj.optString(KEY_TITLE, ""),
|
||||
visitedAt = obj.optLong(KEY_VISITED_AT, 0L)
|
||||
)
|
||||
cachedHistory?.let { return it }
|
||||
synchronized(Companion) {
|
||||
cachedHistory?.let { return it }
|
||||
val json = prefs.getString(keyHistory, null)
|
||||
val loaded = if (json.isNullOrBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
try {
|
||||
val arr = JSONArray(json)
|
||||
List(arr.length()) { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
HistoryItem(
|
||||
url = obj.getString(KEY_URL),
|
||||
title = obj.optString(KEY_TITLE, ""),
|
||||
visitedAt = obj.optLong(KEY_VISITED_AT, 0L)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
cachedHistory = loaded
|
||||
return loaded
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,19 +51,26 @@ class HistoryRepository(context: Context) {
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
prefs.edit().remove(keyHistory).apply()
|
||||
synchronized(Companion) {
|
||||
cachedHistory = emptyList()
|
||||
prefs.edit().remove(keyHistory).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(list: List<HistoryItem>) {
|
||||
val arr = JSONArray()
|
||||
list.forEach { item ->
|
||||
arr.put(JSONObject().apply {
|
||||
put(KEY_URL, item.url)
|
||||
put(KEY_TITLE, item.title)
|
||||
put(KEY_VISITED_AT, item.visitedAt)
|
||||
})
|
||||
val snapshot = list.toList()
|
||||
synchronized(Companion) {
|
||||
cachedHistory = snapshot
|
||||
val arr = JSONArray()
|
||||
snapshot.forEach { item ->
|
||||
arr.put(JSONObject().apply {
|
||||
put(KEY_URL, item.url)
|
||||
put(KEY_TITLE, item.title)
|
||||
put(KEY_VISITED_AT, item.visitedAt)
|
||||
})
|
||||
}
|
||||
prefs.edit().putString(keyHistory, arr.toString()).apply()
|
||||
}
|
||||
prefs.edit().putString(keyHistory, arr.toString()).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -63,5 +79,7 @@ class HistoryRepository(context: Context) {
|
||||
private const val KEY_TITLE = "title"
|
||||
private const val KEY_VISITED_AT = "visitedAt"
|
||||
const val MAX_ENTRIES = 50
|
||||
@Volatile
|
||||
private var cachedHistory: List<HistoryItem>? = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,31 +58,41 @@ data class PlaybackRecord(
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放历史持久化管理(SharedPreferences + JSON)
|
||||
* 播放历史持久化管理(内存缓存 + SharedPreferences 异步落盘,避免播放中高频重复解析 JSON)
|
||||
*/
|
||||
class PlaybackHistoryRepository(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun getAll(): List<PlaybackRecord> {
|
||||
val json = prefs.getString(KEY_RECORDS, null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(json)
|
||||
List(arr.length()) { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
PlaybackRecord(
|
||||
url = obj.optString("url", ""),
|
||||
title = obj.optString("title", ""),
|
||||
positionMs = obj.optInt("positionMs", 0),
|
||||
durationMs = obj.optInt("durationMs", 0),
|
||||
timestamp = obj.optLong("timestamp", 0),
|
||||
series = obj.optString("series", ""),
|
||||
seriesTitle = obj.optString("seriesTitle", ""),
|
||||
episodeName = obj.optString("episodeName", "")
|
||||
)
|
||||
}.filter { it.url.isNotBlank() }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
cachedRecords?.let { return it }
|
||||
synchronized(Companion) {
|
||||
cachedRecords?.let { return it }
|
||||
val json = prefs.getString(KEY_RECORDS, null)
|
||||
val loaded = if (json.isNullOrBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
try {
|
||||
val arr = JSONArray(json)
|
||||
List(arr.length()) { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
PlaybackRecord(
|
||||
url = obj.optString("url", ""),
|
||||
title = obj.optString("title", ""),
|
||||
positionMs = obj.optInt("positionMs", 0),
|
||||
durationMs = obj.optInt("durationMs", 0),
|
||||
timestamp = obj.optLong("timestamp", 0),
|
||||
series = obj.optString("series", ""),
|
||||
seriesTitle = obj.optString("seriesTitle", ""),
|
||||
episodeName = obj.optString("episodeName", "")
|
||||
)
|
||||
}.filter { it.url.isNotBlank() }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
cachedRecords = loaded
|
||||
return loaded
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,29 +171,38 @@ class PlaybackHistoryRepository(context: Context) {
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
prefs.edit().remove(KEY_RECORDS).apply()
|
||||
synchronized(Companion) {
|
||||
cachedRecords = emptyList()
|
||||
prefs.edit().remove(KEY_RECORDS).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(list: List<PlaybackRecord>) {
|
||||
val arr = JSONArray()
|
||||
list.forEach { r ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("url", r.url)
|
||||
put("title", r.title)
|
||||
put("positionMs", r.positionMs)
|
||||
put("durationMs", r.durationMs)
|
||||
put("timestamp", r.timestamp)
|
||||
if (r.series.isNotBlank()) put("series", r.series)
|
||||
if (r.seriesTitle.isNotBlank()) put("seriesTitle", r.seriesTitle)
|
||||
if (r.episodeName.isNotBlank()) put("episodeName", r.episodeName)
|
||||
})
|
||||
val snapshot = list.toList()
|
||||
synchronized(Companion) {
|
||||
cachedRecords = snapshot
|
||||
val arr = JSONArray()
|
||||
snapshot.forEach { r ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("url", r.url)
|
||||
put("title", r.title)
|
||||
put("positionMs", r.positionMs)
|
||||
put("durationMs", r.durationMs)
|
||||
put("timestamp", r.timestamp)
|
||||
if (r.series.isNotBlank()) put("series", r.series)
|
||||
if (r.seriesTitle.isNotBlank()) put("seriesTitle", r.seriesTitle)
|
||||
if (r.episodeName.isNotBlank()) put("episodeName", r.episodeName)
|
||||
})
|
||||
}
|
||||
prefs.edit().putString(KEY_RECORDS, arr.toString()).apply()
|
||||
}
|
||||
prefs.edit().putString(KEY_RECORDS, arr.toString()).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "tv_browser_playback_history"
|
||||
private const val KEY_RECORDS = "records"
|
||||
private const val MAX_RECORDS = 30
|
||||
@Volatile
|
||||
private var cachedRecords: List<PlaybackRecord>? = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +139,9 @@ class PlayerActivity : AppCompatActivity() {
|
||||
setContentView(binding.root)
|
||||
|
||||
historyRepo = PlaybackHistoryRepository(this)
|
||||
val savedSpeed = AppSettings.getPlaybackSpeed(this)
|
||||
val savedSpeedIdx = speeds.indexOfFirst { kotlin.math.abs(it - savedSpeed) < 0.01f }
|
||||
if (savedSpeedIdx >= 0) currentSpeedIndex = savedSpeedIdx
|
||||
|
||||
val rawUrl = intent.getStringExtra(EXTRA_VIDEO_URL).orEmpty()
|
||||
val pageUrlExtra = intent.getStringExtra(EXTRA_PAGE_URL)?.takeIf { it.isNotBlank() }
|
||||
@@ -300,6 +303,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
|
||||
private fun initViews() {
|
||||
binding.tvVideoTitle.text = videoTitle
|
||||
binding.btnSpeed.text = "${speeds[currentSpeedIndex]}x"
|
||||
|
||||
binding.btnBack.setOnClickListener { finish() }
|
||||
binding.btnExternal.setOnClickListener { launchExternalPlayer() }
|
||||
@@ -1024,6 +1028,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
private fun togglePlaybackSpeed() {
|
||||
currentSpeedIndex = (currentSpeedIndex + 1) % speeds.size
|
||||
val targetSpeed = speeds[currentSpeedIndex]
|
||||
AppSettings.setPlaybackSpeed(this, targetSpeed)
|
||||
player?.setPlaybackSpeed(targetSpeed)
|
||||
binding.btnSpeed.text = "${targetSpeed}x"
|
||||
showFeedback("倍速:${targetSpeed}x")
|
||||
@@ -1407,14 +1412,35 @@ class PlayerActivity : AppCompatActivity() {
|
||||
/** 触摸选集面板外部区域时直接收起选集面板,并消费本次手势避免误触底层控制栏 */
|
||||
private var dismissingEpisodePanelTouch = false
|
||||
|
||||
/** 触屏手势控制(手机端):左右滑动调节进度、左侧上下滑动调节亮度、右侧上下滑动调节音量 */
|
||||
private var touchGestureEligible = false
|
||||
private var touchGestureMode = 0 // 0=未触发, 1=水平进度, 2=左侧亮度, 3=右侧音量
|
||||
private var touchDownX = 0f
|
||||
private var touchDownY = 0f
|
||||
private var gestureStartPos = 0L
|
||||
private var gestureTargetPos = 0L
|
||||
private var gestureStartBrightness = 0.5f
|
||||
private var gestureStartVolume = 0
|
||||
|
||||
override fun dispatchTouchEvent(ev: android.view.MotionEvent): Boolean {
|
||||
if (ev.actionMasked == android.view.MotionEvent.ACTION_DOWN) {
|
||||
if (isEpisodePanelVisible() && !isTouchInsideView(ev.rawX, ev.rawY, binding.episodePanel)) {
|
||||
hideEpisodePanel()
|
||||
dismissingEpisodePanelTouch = true
|
||||
touchGestureEligible = false
|
||||
return true
|
||||
}
|
||||
dismissingEpisodePanelTouch = false
|
||||
touchGestureMode = 0
|
||||
touchDownX = ev.rawX
|
||||
touchDownY = ev.rawY
|
||||
// 仅在非选集面板、且未点在顶部/底部控制栏或暂停浮层上时允许滑动手势
|
||||
val onOsdBars = isOsdVisible() && (
|
||||
isTouchInsideView(ev.rawX, ev.rawY, binding.topBar) ||
|
||||
isTouchInsideView(ev.rawX, ev.rawY, binding.bottomBar)
|
||||
)
|
||||
val onPauseOverlay = isTouchInsideView(ev.rawX, ev.rawY, binding.pauseOverlay)
|
||||
touchGestureEligible = !isEpisodePanelVisible() && !onOsdBars && !onPauseOverlay
|
||||
} else if (dismissingEpisodePanelTouch) {
|
||||
if (ev.actionMasked == android.view.MotionEvent.ACTION_UP ||
|
||||
ev.actionMasked == android.view.MotionEvent.ACTION_CANCEL
|
||||
@@ -1422,6 +1448,90 @@ class PlayerActivity : AppCompatActivity() {
|
||||
dismissingEpisodePanelTouch = false
|
||||
}
|
||||
return true
|
||||
} else if (touchGestureEligible) {
|
||||
when (ev.actionMasked) {
|
||||
android.view.MotionEvent.ACTION_POINTER_DOWN -> {
|
||||
touchGestureEligible = false
|
||||
touchGestureMode = 0
|
||||
}
|
||||
android.view.MotionEvent.ACTION_MOVE -> {
|
||||
val dx = ev.rawX - touchDownX
|
||||
val dy = ev.rawY - touchDownY
|
||||
val slop = android.view.ViewConfiguration.get(this).scaledTouchSlop * 1.5f
|
||||
if (touchGestureMode == 0 && (kotlin.math.abs(dx) > slop || kotlin.math.abs(dy) > slop)) {
|
||||
val screenW = resources.displayMetrics.widthPixels.coerceAtLeast(1)
|
||||
if (kotlin.math.abs(dx) > kotlin.math.abs(dy) * 1.2f) {
|
||||
touchGestureMode = 1
|
||||
gestureStartPos = player?.currentPosition?.coerceAtLeast(0L) ?: 0L
|
||||
gestureTargetPos = gestureStartPos
|
||||
isSeeking = true
|
||||
} else if (kotlin.math.abs(dy) > kotlin.math.abs(dx) * 1.2f) {
|
||||
if (touchDownX < screenW / 2f) {
|
||||
touchGestureMode = 2
|
||||
val curB = window.attributes.screenBrightness
|
||||
gestureStartBrightness = if (curB in 0.01f..1.0f) curB else 0.5f
|
||||
} else {
|
||||
touchGestureMode = 3
|
||||
val am = getSystemService(AUDIO_SERVICE) as? android.media.AudioManager
|
||||
gestureStartVolume = am?.getStreamVolume(android.media.AudioManager.STREAM_MUSIC) ?: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
when (touchGestureMode) {
|
||||
1 -> {
|
||||
val p = player
|
||||
if (p != null) {
|
||||
val screenW = resources.displayMetrics.widthPixels.coerceAtLeast(1).toFloat()
|
||||
val offsetMs = ((dx / screenW) * 180_000f).toLong()
|
||||
val dur = p.duration
|
||||
val max = if (dur != C.TIME_UNSET && dur > 0) dur else Long.MAX_VALUE
|
||||
gestureTargetPos = (gestureStartPos + offsetMs).coerceIn(0L, max)
|
||||
val realOffsetSec = (gestureTargetPos - gestureStartPos) / 1000
|
||||
val sign = if (realOffsetSec >= 0) "+" else ""
|
||||
showFeedback("${sign}${realOffsetSec}s (${formatTime(gestureTargetPos)})")
|
||||
}
|
||||
return true
|
||||
}
|
||||
2 -> {
|
||||
val screenH = resources.displayMetrics.heightPixels.coerceAtLeast(1).toFloat()
|
||||
val delta = -dy / (screenH * 0.75f)
|
||||
val targetB = (gestureStartBrightness + delta).coerceIn(0.05f, 1.0f)
|
||||
val lp = window.attributes
|
||||
lp.screenBrightness = targetB
|
||||
window.attributes = lp
|
||||
showFeedback("亮度:${(targetB * 100).toInt()}%")
|
||||
return true
|
||||
}
|
||||
3 -> {
|
||||
val am = getSystemService(AUDIO_SERVICE) as? android.media.AudioManager
|
||||
if (am != null) {
|
||||
val maxVol = am.getStreamMaxVolume(android.media.AudioManager.STREAM_MUSIC).coerceAtLeast(1)
|
||||
val screenH = resources.displayMetrics.heightPixels.coerceAtLeast(1).toFloat()
|
||||
val deltaVol = ((-dy / (screenH * 0.75f)) * maxVol).toInt()
|
||||
val targetVol = (gestureStartVolume + deltaVol).coerceIn(0, maxVol)
|
||||
am.setStreamVolume(android.media.AudioManager.STREAM_MUSIC, targetVol, 0)
|
||||
showFeedback("音量:${targetVol * 100 / maxVol}%")
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
|
||||
val mode = touchGestureMode
|
||||
touchGestureMode = 0
|
||||
touchGestureEligible = false
|
||||
if (mode == 1) {
|
||||
isSeeking = false
|
||||
if (ev.actionMasked == android.view.MotionEvent.ACTION_UP) {
|
||||
player?.seekTo(gestureTargetPos)
|
||||
updateProgress()
|
||||
}
|
||||
return true
|
||||
} else if (mode == 2 || mode == 3) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.dispatchTouchEvent(ev)
|
||||
}
|
||||
|
||||
@@ -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)。
|
||||
*/
|
||||
val JS_DOCUMENT_START_HOOK = """
|
||||
(function() {
|
||||
(function __tvHook() {
|
||||
if (typeof window.artplayerPluginDanmaku !== 'function') {
|
||||
window.artplayerPluginDanmaku = function() {
|
||||
return function() {
|
||||
@@ -424,6 +424,33 @@ object VideoSnifferHelper {
|
||||
} catch (_) {}
|
||||
});
|
||||
} catch (e) {}
|
||||
// 旧版 WebView(不支持 DOCUMENT_START_SCRIPT)兜底:将 Hook 自动传递到可访问的子 iframe
|
||||
try {
|
||||
function injectChildFrames() {
|
||||
var fs = document.getElementsByTagName('iframe');
|
||||
for (var i = 0; i < fs.length; i++) {
|
||||
try {
|
||||
var f = fs[i];
|
||||
var cw = f.contentWindow;
|
||||
if (cw && !cw.__tvSniffInstalled) {
|
||||
cw.eval('(' + __tvHook.toString() + ')()');
|
||||
}
|
||||
if (!f.__tvHookBound) {
|
||||
f.__tvHookBound = true;
|
||||
f.addEventListener('load', function() {
|
||||
try {
|
||||
var w = this.contentWindow;
|
||||
if (w && !w.__tvSniffInstalled) w.eval('(' + __tvHook.toString() + ')()');
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
injectChildFrames();
|
||||
document.addEventListener('DOMContentLoaded', injectChildFrames);
|
||||
window.addEventListener('load', injectChildFrames);
|
||||
} catch (e) {}
|
||||
})();
|
||||
""".trimIndent()
|
||||
|
||||
|
||||
@@ -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.URL
|
||||
import java.net.URLEncoder
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/** 一条聚合搜索结果 */
|
||||
data class VodResult(
|
||||
@@ -24,13 +31,9 @@ data class VodResult(
|
||||
/**
|
||||
* 跨站聚合搜片:在用户收藏的影视站里同时搜索同一个片名。
|
||||
*
|
||||
* 绝大多数盗版影视站都基于苹果 CMS(MacCMS),它的搜索页通常要验证码,
|
||||
* 但联想接口 `/index.php/ajax/suggest?mid=1&wd=关键词` 不需要验证码,返回 JSON:
|
||||
* `{code:1, list:[{id, name, en, pic}]}`。拿到 id 后再拼出详情页地址。
|
||||
*
|
||||
* 详情页地址各站伪静态规则不同(/voddetail/123.html、/detail/123.html …),
|
||||
* 首次搜索某站时抓一次首页,找出一个详情页链接推断模板并缓存;找不到时用 MacCMS 动态路由。
|
||||
* 非苹果 CMS 站点接口不存在 / 返回非 JSON,直接忽略。
|
||||
* 1. 苹果 CMS(MacCMS)联想接口 `/index.php/ajax/suggest?mid=1&wd=关键词`,无需验证码直接返回 JSON。
|
||||
* 2. WordPress / 现代影视站(如 4K影视 4kvm.tv、厂长资源 czzy77.com 等)HTML 搜索页兜底解析(`/?s=关键词`、`/xssearch?q=关键词`)。
|
||||
* 3. 对最近一次搜索词缓存 2 分钟结果,重复查看秒开。
|
||||
*/
|
||||
class VodSearchHelper(context: Context) {
|
||||
|
||||
@@ -42,7 +45,7 @@ class VodSearchHelper(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var executor: ExecutorService? = null
|
||||
private val runningFutures = ArrayList<java.util.concurrent.Future<*>>()
|
||||
@Volatile private var generation = 0
|
||||
|
||||
/** 收藏里去重后的站点(按域名),返回 (origin, 站点名) */
|
||||
@@ -62,65 +65,145 @@ class VodSearchHelper(context: Context) {
|
||||
|
||||
fun search(keyword: String, sites: List<Pair<String, String>>, userAgent: String, listener: Listener) {
|
||||
cancel()
|
||||
val trimmedKw = keyword.trim()
|
||||
val cached = getCachedSearch(trimmedKw, sites.size)
|
||||
if (cached != null) {
|
||||
mainHandler.post {
|
||||
listener.onSiteDone("缓存", cached, sites.size, sites.size)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val gen = ++generation
|
||||
val pool = Executors.newFixedThreadPool(THREADS).also { executor = it }
|
||||
val total = sites.size
|
||||
var done = 0
|
||||
val aggregated = ArrayList<VodResult>()
|
||||
val cookies = CookieManager.getInstance()
|
||||
for ((origin, siteName) in sites) {
|
||||
val cookie = runCatching { cookies.getCookie(origin) }.getOrNull()
|
||||
pool.execute {
|
||||
val results = runCatching { searchSite(origin, siteName, keyword, userAgent, cookie) }
|
||||
.onFailure { Log.d(TAG, "$origin failed: ${it.message}") }
|
||||
.getOrDefault(emptyList())
|
||||
mainHandler.post {
|
||||
if (gen != generation) return@post
|
||||
done++
|
||||
listener.onSiteDone(siteName, results, done, total)
|
||||
synchronized(runningFutures) {
|
||||
runningFutures.clear()
|
||||
for ((origin, siteName) in sites) {
|
||||
val cookie = runCatching { cookies.getCookie(origin) }.getOrNull()
|
||||
val future = sharedExecutor.submit {
|
||||
if (gen != generation) return@submit
|
||||
val results = runCatching { searchSite(origin, siteName, trimmedKw, userAgent, cookie) }
|
||||
.onFailure { Log.d(TAG, "$origin failed: ${it.message}") }
|
||||
.getOrDefault(emptyList())
|
||||
mainHandler.post {
|
||||
if (gen != generation) return@post
|
||||
done++
|
||||
if (results.isNotEmpty()) aggregated.addAll(results)
|
||||
if (done == total && aggregated.isNotEmpty()) {
|
||||
putCachedSearch(trimmedKw, total, aggregated.toList())
|
||||
}
|
||||
listener.onSiteDone(siteName, results, done, total)
|
||||
}
|
||||
}
|
||||
runningFutures.add(future)
|
||||
}
|
||||
}
|
||||
pool.shutdown()
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
generation++
|
||||
executor?.shutdownNow()
|
||||
executor = null
|
||||
synchronized(runningFutures) {
|
||||
runningFutures.forEach { it.cancel(true) }
|
||||
runningFutures.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchSite(
|
||||
origin: String, siteName: String, keyword: String, ua: String, cookie: String?
|
||||
): List<VodResult> {
|
||||
val kw = URLEncoder.encode(keyword, "UTF-8")
|
||||
val body = httpGet("$origin/index.php/ajax/suggest?mid=1&wd=$kw&limit=30", ua, cookie, origin)
|
||||
?: return emptyList()
|
||||
val json = runCatching { JSONObject(body.trim()) }.getOrNull() ?: return emptyList()
|
||||
val list = json.optJSONArray("list") ?: return emptyList()
|
||||
if (list.length() == 0) return emptyList()
|
||||
val host = Uri.parse(origin).host.orEmpty()
|
||||
val sample = list.optJSONObject(0)
|
||||
val template = detailTemplate(
|
||||
origin, host, ua, cookie,
|
||||
sample?.optString("id").orEmpty(), sample?.optString("name").orEmpty().trim()
|
||||
)
|
||||
val out = ArrayList<VodResult>()
|
||||
for (i in 0 until list.length()) {
|
||||
val o = list.optJSONObject(i) ?: continue
|
||||
val id = o.optString("id").ifBlank { o.optString("vod_id") }
|
||||
val name = o.optString("name").ifBlank { o.optString("vod_name") }.trim()
|
||||
if (id.isBlank() || name.isBlank()) continue
|
||||
val path = template.replace("{id}", id)
|
||||
out.add(VodResult(name, siteName, host, if (path.startsWith("http")) path else origin + path))
|
||||
|
||||
// 1. 尝试 MacCMS 标准 Ajax JSON 接口
|
||||
val body = httpGet("$origin/index.php/ajax/suggest?mid=1&wd=$kw&limit=30", ua, cookie, origin)
|
||||
if (body != null) {
|
||||
val json = runCatching { JSONObject(body.trim()) }.getOrNull()
|
||||
val list = json?.optJSONArray("list")
|
||||
if (list != null && list.length() > 0) {
|
||||
val sample = list.optJSONObject(0)
|
||||
val template = detailTemplate(
|
||||
origin, host, ua, cookie,
|
||||
sample?.optString("id").orEmpty(), sample?.optString("name").orEmpty().trim()
|
||||
)
|
||||
val out = ArrayList<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(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("–", "-")
|
||||
.trim()
|
||||
if (rawTitle.length < 2 || rawTitle.length > 60) continue
|
||||
if (BAD_LINK_TEXT.containsMatchIn(rawTitle)) continue
|
||||
if (!rawTitle.lowercase().contains(kwLower) && kwLower.length >= 2) continue
|
||||
|
||||
if (href.startsWith("//")) href = Uri.parse(origin).scheme + ":" + href
|
||||
else if (href.startsWith("/")) href = origin + href
|
||||
if (!href.startsWith("http")) continue
|
||||
val u = runCatching { Uri.parse(href) }.getOrNull() ?: continue
|
||||
if (u.host != null && u.host != host) continue
|
||||
if (!seenUrls.add(href)) continue
|
||||
|
||||
out.add(VodResult(rawTitle, siteName, host, href))
|
||||
if (out.size >= 15) break
|
||||
}
|
||||
if (out.isNotEmpty()) {
|
||||
Log.d(TAG, "$origin -> ${out.size} results (html $searchPath)")
|
||||
return out
|
||||
}
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* 推断详情页地址模板,如 `/voddetail/{id}.html`;结果按域名缓存。
|
||||
* 候选:首页里找到的本站详情链接 + 常见伪静态规则,逐个用 [sampleId] 实际请求验证
|
||||
* (返回 2xx 且页面里出现片名 [sampleName] 才算对)。
|
||||
*/
|
||||
private fun detailTemplate(
|
||||
origin: String, host: String, ua: String, cookie: String?, sampleId: String, sampleName: String
|
||||
@@ -129,7 +212,6 @@ class VodSearchHelper(context: Context) {
|
||||
val candidates = LinkedHashSet<String>()
|
||||
val html = runCatching { httpGet("$origin/", ua, cookie, origin, ajax = false) }.getOrNull()
|
||||
if (html != null) {
|
||||
// 第一个匹配可能是广告外链,逐个找本站链接
|
||||
for (m in DETAIL_HREF.findAll(html)) {
|
||||
var href = m.groupValues[1]
|
||||
if (href.startsWith("//")) href = Uri.parse(origin).scheme + ":" + href
|
||||
@@ -156,13 +238,16 @@ class VodSearchHelper(context: Context) {
|
||||
return tpl
|
||||
}
|
||||
}
|
||||
// 都没验证通过(可能是网络问题),不缓存,下次再试
|
||||
return candidates.first()
|
||||
}
|
||||
|
||||
private fun httpGet(url: String, ua: String, cookie: String?, referer: String, ajax: Boolean = true): String? {
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
try {
|
||||
if (conn is HttpsURLConnection) {
|
||||
conn.sslSocketFactory = lenientSslSocketFactory
|
||||
conn.hostnameVerifier = lenientHostnameVerifier
|
||||
}
|
||||
conn.connectTimeout = TIMEOUT_MS
|
||||
conn.readTimeout = TIMEOUT_MS
|
||||
conn.instanceFollowRedirects = true
|
||||
@@ -173,7 +258,6 @@ class VodSearchHelper(context: Context) {
|
||||
if (!cookie.isNullOrBlank()) conn.setRequestProperty("Cookie", cookie)
|
||||
if (conn.responseCode !in 200..299) return null
|
||||
val bytes = conn.inputStream.use { input ->
|
||||
// 首页可能很大,最多读 1MB
|
||||
val buf = java.io.ByteArrayOutputStream()
|
||||
val chunk = ByteArray(16 * 1024)
|
||||
while (buf.size() < MAX_BYTES) {
|
||||
@@ -195,6 +279,46 @@ class VodSearchHelper(context: Context) {
|
||||
private const val THREADS = 4
|
||||
private const val TIMEOUT_MS = 6000
|
||||
private const val MAX_BYTES = 1024 * 1024
|
||||
private const val CACHE_TTL_MS = 120_000L
|
||||
|
||||
@Volatile private var lastSearchKey: String = ""
|
||||
@Volatile private var lastSearchTime: Long = 0L
|
||||
@Volatile private var lastSearchResults: List<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 详情页伪静态规则(最后一个是动态路由,伪静态未开时也能用) */
|
||||
private val COMMON_TEMPLATES = listOf(
|
||||
"/voddetail/{id}.html",
|
||||
@@ -204,7 +328,16 @@ class VodSearchHelper(context: Context) {
|
||||
)
|
||||
private val DETAIL_HREF =
|
||||
Regex("href=[\"']([^\"'\\s]*?detail[^\"'\\s]*?\\d+[^\"'\\s]*?)[\"']", RegexOption.IGNORE_CASE)
|
||||
/** 明显不是苹果 CMS 的大站,不浪费请求 */
|
||||
|
||||
/** 匹配 4kvm (/movies/..., /tvshows/...)、厂长 (/movie/...)、通用详情链接 */
|
||||
private val HTML_RESULT_LINK = Regex(
|
||||
"<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(
|
||||
"baidu.com", "bing.com", "google.com", "sogou.com", "so.com", "bilibili.com",
|
||||
"iqiyi.com", "youku.com", "qq.com", "mgtv.com", "douban.com", "youtube.com",
|
||||
|
||||
Reference in New Issue
Block a user