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