This commit is contained in:
haomingming
2026-11-09 15:49:32 +08:00
parent 227a39552d
commit 76ec185e76
49 changed files with 1412 additions and 1094 deletions
+15
View File
@@ -0,0 +1,15 @@
* text=auto
*.kt text eol=lf
*.kts text eol=lf
*.xml text eol=lf
*.html text eol=lf
*.js text eol=lf
*.md text eol=lf
*.properties text eol=lf
*.toml text eol=lf
*.pro text eol=lf
*.sh text eol=lf
*.bat text eol=crlf
*.png binary
*.webp binary
*.jar binary
+12 -1
View File
@@ -1,7 +1,18 @@
/build/ /build/
.idea/ .idea/
.gradle/* .gradle/*
.kotlin/
*/release */release
*/debug */debug
*/build/ */build/
local.properties local.properties
*.apk
*.aab
*.ap_
*.dex
*.class
/classes.jar
*.jks
*.keystore
.DS_Store
Thumbs.db
+2 -4
View File
@@ -42,13 +42,9 @@ android {
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.material)
implementation(libs.constraintlayout)
implementation(libs.leanback)
implementation(libs.fragment.ktx) implementation(libs.fragment.ktx)
implementation(libs.recyclerview) implementation(libs.recyclerview)
implementation(libs.nanohttpd) implementation(libs.nanohttpd)
implementation(libs.nanohttpd.websocket)
implementation(libs.zxing.core) implementation(libs.zxing.core)
// 原生播放器:ExoPlayer(支持 HLS、自定义请求头、倍速) // 原生播放器:ExoPlayer(支持 HLS、自定义请求头、倍速)
implementation(libs.media3.exoplayer) implementation(libs.media3.exoplayer)
@@ -56,4 +52,6 @@ dependencies {
implementation(libs.media3.ui) implementation(libs.media3.ui)
// 页面执行前注入脚本(可进入所有 iframe) // 页面执行前注入脚本(可进入所有 iframe)
implementation(libs.androidx.webkit) implementation(libs.androidx.webkit)
testImplementation(libs.junit)
} }
+2 -8
View File
@@ -17,7 +17,7 @@
<application <application
android:name=".TVBrowserApplication" android:name=".TVBrowserApplication"
android:allowBackup="true" android:allowBackup="false"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:banner="@drawable/banner" android:banner="@drawable/banner"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -51,12 +51,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".VideoWebActivity"
android:configChanges="orientation|screenSize"
android:exported="false"
android:theme="@style/Theme.TVBrowser.NoActionBar" />
<activity <activity
android:name=".PlayerActivity" android:name=".PlayerActivity"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize"
@@ -65,7 +59,7 @@
android:supportsPictureInPicture="true" android:supportsPictureInPicture="true"
android:theme="@style/Theme.TVBrowser.NoActionBar" /> android:theme="@style/Theme.TVBrowser.NoActionBar" />
<!-- 局域网快传 APK 自动安装 FileProvider --> <!-- 局域网快传 APK / 视频 FileProvider -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
+16 -5
View File
@@ -354,6 +354,7 @@
(function() { (function() {
var base = window.location.origin; var base = window.location.origin;
var cmdUrl = base + '/cmd'; var cmdUrl = base + '/cmd';
var tvToken = '__TV_SESSION_TOKEN__';
var statusDot = document.getElementById('statusDot'); var statusDot = document.getElementById('statusDot');
var statusText = document.getElementById('statusText'); var statusText = document.getElementById('statusText');
@@ -361,11 +362,12 @@
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open('POST', cmdUrl, true); xhr.open('POST', cmdUrl, true);
xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json');
xhr.setRequestHeader('X-TV-Token', tvToken);
xhr.onreadystatechange = function() { xhr.onreadystatechange = function() {
if (xhr.readyState === 4) { if (xhr.readyState === 4) {
var ok = (xhr.status === 200); var ok = (xhr.status === 200);
statusDot.classList.toggle('disconnected', !ok); statusDot.classList.toggle('disconnected', !ok);
statusText.textContent = ok ? '已连接电视' : '连接异常,请重试'; statusText.textContent = ok ? '已连接电视' : '连接异常,请刷新页面重试';
} }
}; };
xhr.send(JSON.stringify(body)); xhr.send(JSON.stringify(body));
@@ -415,6 +417,7 @@
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
var uploadUrl = base + '/upload?filename=' + encodeURIComponent(file.name); var uploadUrl = base + '/upload?filename=' + encodeURIComponent(file.name);
xhr.open('POST', uploadUrl, true); xhr.open('POST', uploadUrl, true);
xhr.setRequestHeader('X-TV-Token', tvToken);
uploadProgressContainer.style.display = 'block'; uploadProgressContainer.style.display = 'block';
uploadProgressBar.style.width = '0%'; uploadProgressBar.style.width = '0%';
@@ -443,7 +446,7 @@
fileInput.value = ''; fileInput.value = '';
}, 4000); }, 4000);
} else { } else {
uploadProgressText.textContent = '❌ 传输失败,请重试'; uploadProgressText.textContent = '❌ 传输失败,请检查文件格式或刷新重试';
uploadProgressText.style.color = '#ef4444'; uploadProgressText.style.color = '#ef4444';
} }
}; };
@@ -523,7 +526,7 @@
} }
}, { passive: true }); }, { passive: true });
// 电脑端鼠标模拟测试 // 电脑端鼠标模拟测试(加节流)
var mouseMoved = false; var mouseMoved = false;
touchpad.addEventListener('mousedown', function(e) { touchpad.addEventListener('mousedown', function(e) {
lastX = e.clientX; lastX = e.clientX;
@@ -538,6 +541,8 @@
lastY = e.clientY; lastY = e.clientY;
if (dx !== 0 || dy !== 0) { if (dx !== 0 || dy !== 0) {
mouseMoved = true; mouseMoved = true;
if (moveThrottle) return;
moveThrottle = setTimeout(function() { moveThrottle = 0; }, 30);
send({ type: 'move', dx: dx, dy: dy }); send({ type: 'move', dx: dx, dy: dy });
} }
}); });
@@ -590,9 +595,15 @@
var check = new XMLHttpRequest(); var check = new XMLHttpRequest();
check.open('POST', cmdUrl, true); check.open('POST', cmdUrl, true);
check.setRequestHeader('Content-Type', 'application/json'); check.setRequestHeader('Content-Type', 'application/json');
check.setRequestHeader('X-TV-Token', tvToken);
check.onload = function() { check.onload = function() {
statusDot.classList.remove('disconnected'); if (check.status === 200) {
statusText.textContent = '已连接电视'; statusDot.classList.remove('disconnected');
statusText.textContent = '已连接电视';
} else {
statusDot.classList.add('disconnected');
statusText.textContent = '连接校验失败,请刷新页面';
}
}; };
check.onerror = function() { check.onerror = function() {
statusDot.classList.add('disconnected'); statusDot.classList.add('disconnected');
@@ -1,7 +1,6 @@
package com.tv.browser package com.tv.browser
import android.content.Context import android.content.Context
import android.net.Uri
import android.webkit.WebResourceResponse import android.webkit.WebResourceResponse
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@@ -97,12 +96,8 @@ object AdBlockHelper {
if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("about:") || lower.startsWith("javascript:")) { if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("about:") || lower.startsWith("javascript:")) {
return false return false
} }
val scheme = try { val scheme = lower.substringBefore(':', "")
Uri.parse(url).scheme?.lowercase() ?: "" return scheme.isNotEmpty() && BLOCKED_SCHEMES.any { s -> s.equals(scheme, ignoreCase = true) }
} catch (_: Exception) {
""
}
return BLOCKED_SCHEMES.any { s -> s.equals(scheme, ignoreCase = true) }
} }
/** /**
@@ -107,7 +107,10 @@ object AppSettings {
/** 剧集分组 key:站点主域 + 剧名(同一站点同一部剧共用片头片尾设置与追剧记录) */ /** 剧集分组 key:站点主域 + 剧名(同一站点同一部剧共用片头片尾设置与追剧记录) */
fun seriesKey(pageUrl: String?, seriesTitle: String): String { fun seriesKey(pageUrl: String?, seriesTitle: String): String {
if (seriesTitle.isBlank()) return "" if (seriesTitle.isBlank()) return ""
val host = pageUrl?.let { runCatching { android.net.Uri.parse(it).host }.getOrNull() } val host = pageUrl?.let {
runCatching { java.net.URI(it.trim()).host }.getOrNull()
?: runCatching { android.net.Uri.parse(it).host }.getOrNull()
}
val domain = ContentFilterHelper.registrableDomain(host) ?: return "" val domain = ContentFilterHelper.registrableDomain(host) ?: return ""
return "$domain|${seriesTitle.trim()}" return "$domain|${seriesTitle.trim()}"
} }
@@ -1,42 +0,0 @@
package com.tv.browser
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import com.tv.browser.databinding.ItemBookmarkBinding
/**
* 收藏列表适配器,支持点击打开、移除。
*/
class BookmarkAdapter(
private val context: Context,
private var list: List<Bookmark>,
private val onOpen: (Bookmark) -> Unit,
private val onRemove: (Bookmark) -> Unit
) : BaseAdapter() {
fun updateList(newList: List<Bookmark>) {
list = newList
notifyDataSetChanged()
}
override fun getCount(): Int = list.size
override fun getItem(position: Int): Bookmark = list[position]
override fun getItemId(position: Int): Long = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
val binding = if (convertView == null) {
ItemBookmarkBinding.inflate(LayoutInflater.from(context), parent, false)
} else {
ItemBookmarkBinding.bind(convertView)
}
val item = getItem(position)
binding.bookmarkTitle.text = item.displayTitle()
binding.bookmarkUrl.text = item.url
binding.root.setOnClickListener { onOpen(item) }
binding.btnRemove.setOnClickListener { onRemove(item) }
return binding.root
}
}
@@ -2,67 +2,39 @@ package com.tv.browser
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
/** /**
* 使用内存缓存 + SharedPreferences 持久化收藏列表。 * 使用 [JsonListStore](内存缓存 + SharedPreferences)持久化收藏列表。
*/ */
class BookmarkRepository(context: Context) { class BookmarkRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val appContext = context.applicationContext
private val keyBookmarks = "bookmarks"
private val keyInitialized = "bookmarks_initialized"
fun getAll(): List<Bookmark> { fun getAll(): List<Bookmark> = store.getAll(appContext) { prefs, parsed ->
cachedBookmarks?.let { return it } val isInitialized = prefs.getBoolean(KEY_INITIALIZED, false)
synchronized(Companion) { val rawJson = prefs.getString(KEY_BOOKMARKS, null)
cachedBookmarks?.let { return it } if (!isInitialized && rawJson == null) {
val isInitialized = prefs.getBoolean(keyInitialized, false) store.save(appContext, DEFAULT_BOOKMARKS)
val json = prefs.getString(keyBookmarks, null) prefs.edit()
if (!isInitialized && json == null) { .putBoolean(KEY_INITIALIZED, true)
save(DEFAULT_BOOKMARKS) .putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION)
prefs.edit() .apply()
.putBoolean(keyInitialized, true) DEFAULT_BOOKMARKS
.putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION) } else {
.apply() migrateDefaults(prefs, parsed)
cachedBookmarks = DEFAULT_BOOKMARKS
return DEFAULT_BOOKMARKS
}
val list = parse(json)
val migrated = migrateDefaults(list)
cachedBookmarks = migrated
return migrated
}
}
private fun parse(json: String?): List<Bookmark> {
if (json.isNullOrBlank()) {
return emptyList()
}
return try {
val arr = JSONArray(json)
List(arr.length()) { i ->
val obj = arr.getJSONObject(i)
Bookmark(
url = obj.getString(KEY_URL),
title = obj.optString(KEY_TITLE, "")
)
}
} catch (_: Exception) {
emptyList()
} }
} }
/** 老用户升级:把之后版本新增的默认站点补到最前面(只补一次) */ /** 老用户升级:把之后版本新增的默认站点补到最前面(只补一次) */
private fun migrateDefaults(list: List<Bookmark>): List<Bookmark> { private fun migrateDefaults(prefs: SharedPreferences, list: List<Bookmark>): List<Bookmark> {
val version = prefs.getInt(KEY_DEFAULTS_VERSION, 1) val version = prefs.getInt(KEY_DEFAULTS_VERSION, 1)
if (version >= DEFAULTS_VERSION) return list if (version >= DEFAULTS_VERSION) return list
val added = (version + 1..DEFAULTS_VERSION) val added = (version + 1..DEFAULTS_VERSION)
.flatMap { DEFAULTS_ADDED_BY_VERSION[it].orEmpty() } .flatMap { DEFAULTS_ADDED_BY_VERSION[it].orEmpty() }
.filter { b -> list.none { it.url == b.url } } .filter { b -> list.none { it.url == b.url } }
val result = if (added.isEmpty()) list else added + list val result = if (added.isEmpty()) list else added + list
if (added.isNotEmpty()) save(result) if (added.isNotEmpty()) store.save(appContext, result)
prefs.edit().putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION).apply() prefs.edit().putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION).apply()
return result return result
} }
@@ -71,38 +43,38 @@ class BookmarkRepository(context: Context) {
val list = getAll().toMutableList() val list = getAll().toMutableList()
list.removeAll { it.url == bookmark.url } list.removeAll { it.url == bookmark.url }
list.add(0, bookmark) list.add(0, bookmark)
save(list) store.save(appContext, list)
} }
fun remove(url: String) { fun remove(url: String) {
val list = getAll().filter { it.url != url } val list = getAll().filter { it.url != url }
save(list) store.save(appContext, list)
} }
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>) {
val snapshot = list.toList()
synchronized(Companion) {
cachedBookmarks = snapshot
val arr = JSONArray()
snapshot.forEach { b ->
arr.put(JSONObject().apply {
put(KEY_URL, b.url)
put(KEY_TITLE, b.title)
})
}
prefs.edit().putString(keyBookmarks, arr.toString()).apply()
}
}
companion object { companion object {
private const val PREFS_NAME = "tv_browser_bookmarks" private const val PREFS_NAME = "tv_browser_bookmarks"
private const val KEY_BOOKMARKS = "bookmarks"
private const val KEY_INITIALIZED = "bookmarks_initialized"
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 private val store = JsonListStore<Bookmark>(
prefsName = PREFS_NAME,
key = KEY_BOOKMARKS,
encode = { b ->
JSONObject().apply {
put(KEY_URL, b.url)
put(KEY_TITLE, b.title)
}
},
decode = { obj ->
val url = obj.optString(KEY_URL, "")
if (url.isBlank()) null else Bookmark(url = url, title = obj.optString(KEY_TITLE, ""))
}
)
/** 默认收藏版本:新增默认站点时 +1,并在 [DEFAULTS_ADDED_BY_VERSION] 登记 */ /** 默认收藏版本:新增默认站点时 +1,并在 [DEFAULTS_ADDED_BY_VERSION] 登记 */
private const val DEFAULTS_VERSION = 4 private const val DEFAULTS_VERSION = 4
@@ -1,87 +0,0 @@
package com.tv.browser
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Window
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import com.tv.browser.databinding.DialogBookmarksBinding
/**
* 收藏夹对话框:列表展示、打开、移除,适配 TV 焦点。
*/
class BookmarksDialog : DialogFragment() {
var onOpenUrl: ((String) -> Unit)? = null
/** 弹窗关闭时回调(用于恢复光标等) */
var onDismissCallback: (() -> Unit)? = null
private val repo: BookmarkRepository by lazy {
BookmarkRepository(requireContext())
}
private var binding: DialogBookmarksBinding? = null
private var adapter: BookmarkAdapter? = null
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
binding = DialogBookmarksBinding.inflate(LayoutInflater.from(requireContext()))
val view = binding!!.root
adapter = BookmarkAdapter(
context = requireContext(),
list = repo.getAll(),
onOpen = { b ->
onOpenUrl?.invoke(b.url)
dismiss()
},
onRemove = { b ->
repo.remove(b.url)
refreshList()
Toast.makeText(requireContext(), getString(R.string.bookmark_removed), Toast.LENGTH_SHORT).show()
}
)
binding!!.bookmarkList.adapter = adapter
binding!!.bookmarkList.setOnItemClickListener { _, _, position, _ ->
adapter?.getItem(position)?.let { onOpenUrl?.invoke(it.url); dismiss() }
}
binding!!.btnClose.setOnClickListener { dismiss() }
refreshList()
val dialog = AlertDialog.Builder(requireContext())
.setView(view)
.create()
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
dialog.setOnDismissListener { onDismissCallback?.invoke() }
dialog.setOnShowListener {
binding?.bookmarkList?.let { list ->
list.requestFocus()
if (list.count > 0) list.setSelection(0)
}
}
return dialog
}
override fun onStart() {
super.onStart()
(activity as? BrowserActivity)?.setCursorOverlayVisible(false, this)
}
override fun onDestroyView() {
(activity as? BrowserActivity)?.setCursorOverlayVisible(true, this)
super.onDestroyView()
binding = null
}
private fun refreshList() {
val list = repo.getAll()
adapter?.updateList(list)
binding?.let { b ->
b.bookmarkList.visibility = if (list.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE
b.emptyHint.visibility = if (list.isEmpty()) android.view.View.VISIBLE else android.view.View.GONE
}
}
}
@@ -8,6 +8,7 @@ import android.content.res.Configuration
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.net.http.SslError import android.net.http.SslError
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -19,7 +20,6 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager import android.view.WindowManager
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.webkit.ConsoleMessage import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler import android.webkit.SslErrorHandler
@@ -35,17 +35,13 @@ import android.widget.ImageView
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.Toast import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.webkit.ScriptHandler import androidx.webkit.ScriptHandler
import com.tv.browser.databinding.ActivityBrowserBinding import com.tv.browser.databinding.ActivityBrowserBinding
import java.io.File import java.io.File
import java.util.Calendar
/** /**
* 视频嗅探条目(支持多线路检测与切换) * 视频嗅探条目(支持多线路检测与切换)
@@ -86,7 +82,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
internal const val KEY_IS_DESKTOP_UA = "key_is_desktop_ua" internal const val KEY_IS_DESKTOP_UA = "key_is_desktop_ua"
internal val ZOOM_OPTIONS = intArrayOf(50, 75, 90, 100, 125, 150, 175, 200) internal val ZOOM_OPTIONS = intArrayOf(50, 75, 90, 100, 125, 150, 175, 200)
private val DIALOG_FRAGMENT_TAGS = listOf("bookmarks", "user_scripts", "remote", "history", "settings") private val DIALOG_FRAGMENT_TAGS = listOf("user_scripts", "remote", "history", "settings")
/** 当前浏览器使用的 UA(播放器 / 后台解析需与网页保持一致,部分站点的视频令牌与 UA 绑定) */ /** 当前浏览器使用的 UA(播放器 / 后台解析需与网页保持一致,部分站点的视频令牌与 UA 绑定) */
fun currentUserAgent(context: Context): String { fun currentUserAgent(context: Context): String {
@@ -104,6 +100,8 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
private var isEditingCards: Boolean = false private var isEditingCards: Boolean = false
private var currentPageTitle: String = "" private var currentPageTitle: String = ""
private var hasDocStartHook: Boolean = false private var hasDocStartHook: Boolean = false
/** 当设备不支持 WebViewCompat DOCUMENT_START 注入时,每个页面仅在加载早期注入一次兜底脚本,避免 onProgressChanged 反复注入 20KB 脚本 */
private var pageDocStartInjected: Boolean = false
private var desktopViewportScript: ScriptHandler? = null private var desktopViewportScript: ScriptHandler? = null
internal lateinit var binding: ActivityBrowserBinding internal lateinit var binding: ActivityBrowserBinding
@@ -137,8 +135,11 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val remoteStatusChecker = object : Runnable { private val remoteStatusChecker = object : Runnable {
override fun run() { override fun run() {
if (!isActivityResumed) return
remoteHelper.updateStatusDot(binding.remoteStatusDot) remoteHelper.updateStatusDot(binding.remoteStatusDot)
binding.homeStatusBar.visibility = View.GONE if (binding.homeStatusBar.visibility != View.GONE) {
binding.homeStatusBar.visibility = View.GONE
}
mainHandler.postDelayed(this, 3000) mainHandler.postDelayed(this, 3000)
} }
} }
@@ -183,14 +184,13 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
remoteHelper.ensureStarted() remoteHelper.ensureStarted()
remoteHelper.initStatusDot(binding.remoteStatusDot) remoteHelper.initStatusDot(binding.remoteStatusDot)
mainHandler.post(remoteStatusChecker)
webView.addJavascriptInterface(FullscreenBridge(), "AndroidFullscreenBridge") webView.addJavascriptInterface(FullscreenBridge(), "AndroidFullscreenBridge")
webView.addJavascriptInterface(VideoBridge(), "AndroidVideoBridge") webView.addJavascriptInterface(VideoBridge(), "AndroidVideoBridge")
webView.addJavascriptInterface(NavGuardBridge(), "AndroidNavGuard") webView.addJavascriptInterface(NavGuardBridge(), "AndroidNavGuard")
webView.addJavascriptInterface(PurifyBridge(), PurifyHelper.BRIDGE_NAME) webView.addJavascriptInterface(PurifyBridge(), PurifyHelper.BRIDGE_NAME)
hasDocStartHook = VideoSnifferHelper.installDocumentStartHook(webView) hasDocStartHook = VideoSnifferHelper.installDocumentStartHook(webView)
Log.d(TAG_VIDEO, "document-start hook installed=$hasDocStartHook") if (BuildConfig.DEBUG) Log.d(TAG_VIDEO, "document-start hook installed=$hasDocStartHook")
val prefs = getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE) val prefs = getSharedPreferences(PREFS_SETTINGS, Context.MODE_PRIVATE)
currentZoomLevel = prefs.getInt(KEY_ZOOM_LEVEL, 100) currentZoomLevel = prefs.getInt(KEY_ZOOM_LEVEL, 100)
@@ -236,7 +236,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val targetUrl = request?.url?.toString() ?: return false val targetUrl = request?.url?.toString() ?: return false
if (AdBlockHelper.isHijackScheme(targetUrl)) { if (AdBlockHelper.isHijackScheme(targetUrl)) {
Log.d("TVBrowser.AdBlock", "Blocked hijack scheme: $targetUrl") if (BuildConfig.DEBUG) Log.d("TVBrowser.AdBlock", "Blocked hijack scheme: $targetUrl")
return true return true
} }
if (request.isForMainFrame == false) return false if (request.isForMainFrame == false) return false
@@ -271,9 +271,13 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
progressBar.visibility = View.VISIBLE progressBar.visibility = View.VISIBLE
if (!isOnHomePage && url != "about:blank") urlBar.setText(url ?: "") if (!isOnHomePage && url != "about:blank") urlBar.setText(url ?: "")
currentPageTitle = "" currentPageTitle = ""
pageDocStartInjected = false
hideKeyboard() hideKeyboard()
snifferController.resetForNewPage() snifferController.resetForNewPage()
if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null) if (!hasDocStartHook) {
view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null)
pageDocStartInjected = true
}
injectDesktopModeFallback(view) injectDesktopModeFallback(view)
view?.evaluateJavascript(VideoControlHelper.JS_ENABLE_FULLSCREEN_ENHANCEMENT, null) view?.evaluateJavascript(VideoControlHelper.JS_ENABLE_FULLSCREEN_ENHANCEMENT, null)
if (AdBlockHelper.isEnabled(this@BrowserActivity)) view?.evaluateJavascript(AdBlockHelper.JS_INJECT_ADBLOCK, null) if (AdBlockHelper.isEnabled(this@BrowserActivity)) view?.evaluateJavascript(AdBlockHelper.JS_INJECT_ADBLOCK, null)
@@ -317,12 +321,13 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
@Deprecated("Deprecated in Java") @Deprecated("Deprecated in Java")
override fun onReceivedError(view: WebView?, errorCode: Int, description: String?, failingUrl: String?) { override fun onReceivedError(view: WebView?, errorCode: Int, description: String?, failingUrl: String?) {
progressBar.visibility = View.GONE if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M && failingUrl == view?.url) {
Toast.makeText(this@BrowserActivity, getString(R.string.error_page), Toast.LENGTH_SHORT).show() progressBar.visibility = View.GONE
Toast.makeText(this@BrowserActivity, getString(R.string.error_page), Toast.LENGTH_SHORT).show()
}
} }
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) { override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
super.onReceivedError(view, request, error)
if (request?.isForMainFrame == true) { if (request?.isForMainFrame == true) {
progressBar.visibility = View.GONE progressBar.visibility = View.GONE
Toast.makeText(this@BrowserActivity, getString(R.string.error_page), Toast.LENGTH_SHORT).show() Toast.makeText(this@BrowserActivity, getString(R.string.error_page), Toast.LENGTH_SHORT).show()
@@ -332,7 +337,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) { override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
val primary = error?.primaryError val primary = error?.primaryError
val isDateError = primary == SslError.SSL_DATE_INVALID || primary == SslError.SSL_EXPIRED || val isDateError = primary == SslError.SSL_DATE_INVALID || primary == SslError.SSL_EXPIRED ||
primary == SslError.SSL_NOTYETVALID || Calendar.getInstance().get(Calendar.YEAR) < 2025 primary == SslError.SSL_NOTYETVALID || NetworkChecker.isSystemClockAbnormal()
val allow = AppSettings.isLenientSsl(this@BrowserActivity) || isDateError val allow = AppSettings.isLenientSsl(this@BrowserActivity) || isDateError
Log.w(TAG_VIDEO, "onReceivedSslError: code=$primary allow=$allow url=${error?.url}") Log.w(TAG_VIDEO, "onReceivedSslError: code=$primary allow=$allow url=${error?.url}")
if (allow) handler?.proceed() else handler?.cancel() if (allow) handler?.proceed() else handler?.cancel()
@@ -342,7 +347,10 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
webView.webChromeClient = object : WebChromeClient() { webView.webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) { override fun onProgressChanged(view: WebView?, newProgress: Int) {
progressBar.progress = newProgress progressBar.progress = newProgress
if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null) if (!hasDocStartHook && !pageDocStartInjected) {
view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null)
pageDocStartInjected = true
}
if (newProgress >= 100) progressBar.visibility = View.GONE if (newProgress >= 100) progressBar.visibility = View.GONE
} }
@@ -353,7 +361,9 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
} }
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
consoleMessage?.let { Log.d(TAG_VIDEO, "JS console [${it.messageLevel()}] ${it.sourceId()}:${it.lineNumber()} ${it.message()}") } if (BuildConfig.DEBUG) {
consoleMessage?.let { Log.d(TAG_VIDEO, "JS console [${it.messageLevel()}] ${it.sourceId()}:${it.lineNumber()} ${it.message()}") }
}
return false return false
} }
@@ -825,7 +835,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
@JavascriptInterface @JavascriptInterface
fun onPlaylist(url: String, finalUrl: String, text: String) { fun onPlaylist(url: String, finalUrl: String, text: String) {
PlaylistCache.put(url, finalUrl, text) PlaylistCache.put(url, finalUrl, text)
Log.d(TAG_VIDEO, "Playlist captured: $url (${text.length} chars)") if (BuildConfig.DEBUG) Log.d(TAG_VIDEO, "Playlist captured: $url (${text.length} chars)")
} }
@JavascriptInterface @JavascriptInterface
@@ -834,7 +844,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
runOnUiThread { runOnUiThread {
snifferController.currentSiteInfo = info snifferController.currentSiteInfo = info
snifferController.currentSiteInfoJson = json snifferController.currentSiteInfoJson = json
Log.d(TAG_VIDEO, "Site info[${info.adapter}]: ${info.displayTitle()} episodes=${info.episodes.size}") if (BuildConfig.DEBUG) Log.d(TAG_VIDEO, "Site info[${info.adapter}]: ${info.displayTitle()} episodes=${info.episodes.size}")
if (AppSettings.isPurifyPage(this@BrowserActivity)) applyPurify(resetDisabled = false) if (AppSettings.isPurifyPage(this@BrowserActivity)) applyPurify(resetDisabled = false)
} }
} }
@@ -872,21 +882,34 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
runOnUiThread { cursorController.move(dx, dy, isOnHomePage) { setToolbarVisible(true) } } runOnUiThread { cursorController.move(dx, dy, isOnHomePage) { setToolbarVisible(true) } }
override fun onRemoteClick() = runOnUiThread { override fun onRemoteClick() = runOnUiThread {
val activePlayer = PlayerActivity.activeInstance?.get()?.takeIf { !it.isFinishing && !it.isDestroyed }
if (activePlayer != null && activePlayer.handleRemoteClick()) return@runOnUiThread
if (!fullscreenHelper.handleRemoteClick()) cursorController.dispatchClick() if (!fullscreenHelper.handleRemoteClick()) cursorController.dispatchClick()
} }
override fun onRemoteScroll(dx: Int, dy: Int) = runOnUiThread { webView.scrollBy(dx, dy) } override fun onRemoteScroll(dx: Int, dy: Int) = runOnUiThread { webView.scrollBy(dx, dy) }
override fun onRemoteKey(keyCode: String) = runOnUiThread { override fun onRemoteKey(keyCode: String) = runOnUiThread {
when (keyCode) {
"volume_up" -> { remoteHelper.adjustVolume(raise = true); return@runOnUiThread }
"volume_down" -> { remoteHelper.adjustVolume(raise = false); return@runOnUiThread }
"mute" -> { remoteHelper.toggleMute(); return@runOnUiThread }
}
val activePlayer = PlayerActivity.activeInstance?.get()?.takeIf { !it.isFinishing && !it.isDestroyed }
if (activePlayer != null) {
if (keyCode == "home") {
activePlayer.finish()
showHomePage()
return@runOnUiThread
}
if (activePlayer.handleRemoteKey(keyCode)) return@runOnUiThread
}
when (keyCode) { when (keyCode) {
"back" -> onBackPressed() "back" -> onBackPressed()
"home" -> showHomePage() "home" -> showHomePage()
"menu" -> if (!isOnHomePage && !isToolbarVisible) setToolbarVisible(true) else showMoreMenu() "menu" -> if (!isOnHomePage && !isToolbarVisible) setToolbarVisible(true) else showMoreMenu()
"refresh" -> webView.reload() "refresh" -> webView.reload()
"fullscreen" -> fullscreenHelper.triggerVideoFullscreen() "fullscreen" -> fullscreenHelper.triggerVideoFullscreen()
"volume_up" -> remoteHelper.adjustVolume(raise = true)
"volume_down" -> remoteHelper.adjustVolume(raise = false)
"mute" -> remoteHelper.toggleMute()
"dpad_left" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT)) "dpad_left" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT))
"dpad_right" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT)) "dpad_right" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT))
"dpad_up" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP)) "dpad_up" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP))
@@ -899,6 +922,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
override fun onRemoteOpenUrl(url: String) { override fun onRemoteOpenUrl(url: String) {
val trimmed = url.trim().takeIf { it.isNotEmpty() } ?: return val trimmed = url.trim().takeIf { it.isNotEmpty() } ?: return
runOnUiThread { runOnUiThread {
PlayerActivity.activeInstance?.get()?.takeIf { !it.isFinishing && !it.isDestroyed }?.finish()
val targetUrl = if (!trimmed.startsWith("http://", true) && !trimmed.startsWith("https://", true)) "https://$trimmed" else trimmed val targetUrl = if (!trimmed.startsWith("http://", true) && !trimmed.startsWith("https://", true)) "https://$trimmed" else trimmed
urlBar.setText(targetUrl) urlBar.setText(targetUrl)
loadUrl(targetUrl) loadUrl(targetUrl)
@@ -909,8 +933,8 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
override fun onRemoteText(text: String) = override fun onRemoteText(text: String) =
runOnUiThread { remoteHelper.handleRemoteText(text, currentFocus, isOnHomePage, webView, urlBar) } runOnUiThread { remoteHelper.handleRemoteText(text, currentFocus, isOnHomePage, webView, urlBar) }
override fun onRemoteFileUploaded(file: File, filename: String) = override fun onRemoteFileUploaded(file: File, filename: String, clientIp: String) =
runOnUiThread { remoteHelper.handleFileUploaded(file, filename) } runOnUiThread { remoteHelper.handleFileUploaded(file, filename, clientIp) }
internal fun showRemoteControlDialog() = RemoteControlDialog().show(supportFragmentManager, "remote") internal fun showRemoteControlDialog() = RemoteControlDialog().show(supportFragmentManager, "remote")
@@ -946,11 +970,16 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
resumeWebMediaIfSuspended() resumeWebMediaIfSuspended()
} }
if (isOnHomePage && ::binding.isInitialized) updateContinueWatchingBanner() if (isOnHomePage && ::binding.isInitialized) updateContinueWatchingBanner()
if (::binding.isInitialized) updateCursorOverlayVisibility() if (::binding.isInitialized) {
updateCursorOverlayVisibility()
mainHandler.removeCallbacks(remoteStatusChecker)
mainHandler.post(remoteStatusChecker)
}
} }
override fun onPause() { override fun onPause() {
isActivityResumed = false isActivityResumed = false
mainHandler.removeCallbacks(remoteStatusChecker)
cancelAutoPlay(markHandled = true) cancelAutoPlay(markHandled = true)
if (::webView.isInitialized) { if (::webView.isInitialized) {
suspendWebMedia() suspendWebMedia()
@@ -961,6 +990,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
override fun onStop() { override fun onStop() {
isActivityResumed = false isActivityResumed = false
mainHandler.removeCallbacks(remoteStatusChecker)
cancelAutoPlay(markHandled = true) cancelAutoPlay(markHandled = true)
if (::webView.isInitialized) { if (::webView.isInitialized) {
suspendWebMedia() suspendWebMedia()
@@ -208,7 +208,8 @@ object BrowserUiHelper {
try { try {
val expireBefore = System.currentTimeMillis() - 3L * 24 * 3600 * 1000 val expireBefore = System.currentTimeMillis() - 3L * 24 * 3600 * 1000
listOfNotNull(extUploads, intUploads).forEach { dir -> listOfNotNull(extUploads, intUploads).forEach { dir ->
dir.listFiles()?.forEach { f -> if (!dir.exists()) return@forEach
dir.walkTopDown().forEach { f ->
if (f.isFile && f.lastModified() in 1 until expireBefore) { if (f.isFile && f.lastModified() in 1 until expireBefore) {
f.delete() f.delete()
} }
@@ -355,30 +356,34 @@ object BrowserUiHelper {
} }
fun checkNetworkDiagnostics(activity: AppCompatActivity) { fun checkNetworkDiagnostics(activity: AppCompatActivity) {
val appContext = activity.applicationContext
val titleStr = activity.getString(R.string.check_network)
val okStr = activity.getString(R.string.internet_ok)
val unreachableStr = activity.getString(R.string.internet_unreachable)
val closeStr = activity.getString(R.string.close)
val progress = AlertDialog.Builder(activity) val progress = AlertDialog.Builder(activity)
.setTitle(activity.getString(R.string.check_network)) .setTitle(titleStr)
.setMessage(activity.getString(R.string.network_checking)) .setMessage(activity.getString(R.string.network_checking))
.setCancelable(false) .setCancelable(false)
.create() .create()
progress.show() progress.show()
Thread { Thread {
val linkStatus = NetworkChecker.getStatusMessage(activity) val linkStatus = NetworkChecker.getStatusMessage(appContext)
val internetReachable = NetworkChecker.isInternetReachable() val internetReachable = NetworkChecker.isInternetReachable()
val internetStatus = if (internetReachable) { val internetStatus = if (internetReachable) okStr else unreachableStr
activity.getString(R.string.internet_ok)
} else {
activity.getString(R.string.internet_unreachable)
}
val currentYear = Calendar.getInstance().get(Calendar.YEAR) val currentYear = Calendar.getInstance().get(Calendar.YEAR)
val timeTip = if (currentYear < 2024) "\n⚠️ 提示:检测到电视系统年份为 ${currentYear} 年,系统时钟可能未同步,浏览器已开启 SSL 兼容放行模式。" else "" val timeTip = if (NetworkChecker.isSystemClockAbnormal()) {
"\n⚠️ 提示:检测到电视系统时间(当前年份:${currentYear})与标准时间存在明显偏差,浏览器已自动开启 SSL 日期兼容放行模式。"
} else ""
activity.runOnUiThread { activity.runOnUiThread {
if (!activity.isFinishing && !activity.isDestroyed) { if (!activity.isFinishing && !activity.isDestroyed) {
progress.dismiss() progress.dismiss()
AlertDialog.Builder(activity) AlertDialog.Builder(activity)
.setTitle(activity.getString(R.string.check_network)) .setTitle(titleStr)
.setMessage("$linkStatus\n$internetStatus$timeTip") .setMessage("$linkStatus\n$internetStatus$timeTip")
.setPositiveButton(activity.getString(R.string.close), null) .setPositiveButton(closeStr, null)
.show() .show()
} }
} }
@@ -1,44 +1,16 @@
package com.tv.browser package com.tv.browser
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
/** /**
* 浏览历史持久化(内存缓存 + SharedPreferences 异步落盘),最多保留 [MAX_ENTRIES] 条。 * 浏览历史持久化(基于 [JsonListStore] 内存缓存 + SharedPreferences 异步落盘),最多保留 [MAX_ENTRIES] 条。
*/ */
class HistoryRepository(context: Context) { class HistoryRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val appContext = context.applicationContext
private val keyHistory = "history"
fun getAll(): List<HistoryItem> { fun getAll(): List<HistoryItem> = store.getAll(appContext)
cachedHistory?.let { return it }
synchronized(Companion) {
cachedHistory?.let { return it }
val json = prefs.getString(keyHistory, null)
val loaded = if (json.isNullOrBlank()) {
emptyList()
} else {
try {
val arr = JSONArray(json)
List(arr.length()) { i ->
val obj = arr.getJSONObject(i)
HistoryItem(
url = obj.getString(KEY_URL),
title = obj.optString(KEY_TITLE, ""),
visitedAt = obj.optLong(KEY_VISITED_AT, 0L)
)
}
} catch (_: Exception) {
emptyList()
}
}
cachedHistory = loaded
return loaded
}
}
/** 添加一条记录:同 URL 只保留最新,总数不超过 [MAX_ENTRIES] */ /** 添加一条记录:同 URL 只保留最新,总数不超过 [MAX_ENTRIES] */
fun add(url: String, title: String) { fun add(url: String, title: String) {
@@ -47,39 +19,38 @@ class HistoryRepository(context: Context) {
val list = getAll().toMutableList() val list = getAll().toMutableList()
list.removeAll { it.url == url } list.removeAll { it.url == url }
list.add(0, HistoryItem(url = url, title = title.trim(), visitedAt = now)) list.add(0, HistoryItem(url = url, title = title.trim(), visitedAt = now))
save(list.take(MAX_ENTRIES)) store.save(appContext, list)
} }
fun clear() { fun clear() = store.clear(appContext)
synchronized(Companion) {
cachedHistory = emptyList()
prefs.edit().remove(keyHistory).apply()
}
}
private fun save(list: List<HistoryItem>) {
val snapshot = list.toList()
synchronized(Companion) {
cachedHistory = snapshot
val arr = JSONArray()
snapshot.forEach { item ->
arr.put(JSONObject().apply {
put(KEY_URL, item.url)
put(KEY_TITLE, item.title)
put(KEY_VISITED_AT, item.visitedAt)
})
}
prefs.edit().putString(keyHistory, arr.toString()).apply()
}
}
companion object { companion object {
private const val PREFS_NAME = "tv_browser_history" private const val PREFS_NAME = "tv_browser_history"
private const val KEY_HISTORY = "history"
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_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 private val store = JsonListStore<HistoryItem>(
prefsName = PREFS_NAME,
key = KEY_HISTORY,
maxEntries = MAX_ENTRIES,
encode = { item ->
JSONObject().apply {
put(KEY_URL, item.url)
put(KEY_TITLE, item.title)
put(KEY_VISITED_AT, item.visitedAt)
}
},
decode = { obj ->
val url = obj.optString(KEY_URL, "")
if (url.isBlank()) null else HistoryItem(
url = url,
title = obj.optString(KEY_TITLE, ""),
visitedAt = obj.optLong(KEY_VISITED_AT, 0L)
)
}
)
} }
} }
@@ -0,0 +1,81 @@
package com.tv.browser
import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject
/**
* 通用 JSON 列表存储引擎(内存缓存 + SharedPreferences 异步落盘 + 容量裁剪):
* 统一 BookmarkRepository、HistoryRepository、PlaybackHistoryRepository 与 UserScriptRepository,
* 消除重复的 synchronized + JSONArray 全量序列化样板代码。
*/
class JsonListStore<T>(
private val prefsName: String,
private val key: String,
private val maxEntries: Int = 0,
private val encode: (T) -> JSONObject,
private val decode: (JSONObject) -> T?
) {
private val lock = Any()
@Volatile
private var cached: List<T>? = null
fun prefs(context: Context): SharedPreferences =
context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
/**
* 获取全量列表(命中内存缓存时直接 O(1) 返回;首次加载时支持通过 [onFirstLoad] 执行默认初始化或版本迁移)。
*/
fun getAll(
context: Context,
onFirstLoad: ((SharedPreferences, List<T>) -> List<T>)? = null
): List<T> {
cached?.let { return it }
synchronized(lock) {
cached?.let { return it }
val sp = prefs(context)
val parsed = parseJson(sp.getString(key, null))
val finalItems = if (onFirstLoad != null) onFirstLoad(sp, parsed) else parsed
cached = finalItems
return finalItems
}
}
fun parseJson(json: String?): List<T> {
if (json.isNullOrBlank()) return emptyList()
return try {
val arr = JSONArray(json)
val out = ArrayList<T>(arr.length())
for (i in 0 until arr.length()) {
val obj = arr.optJSONObject(i) ?: continue
decode(obj)?.let { out.add(it) }
}
out
} catch (_: Exception) {
emptyList()
}
}
fun save(context: Context, list: List<T>) {
val snapshot = if (maxEntries > 0 && list.size > maxEntries) {
list.take(maxEntries)
} else {
list.toList()
}
synchronized(lock) {
cached = snapshot
val arr = JSONArray()
snapshot.forEach { item -> arr.put(encode(item)) }
prefs(context).edit().putString(key, arr.toString()).apply()
}
}
fun clear(context: Context) {
synchronized(lock) {
cached = emptyList()
prefs(context).edit().remove(key).apply()
}
}
}
@@ -8,14 +8,43 @@ import java.net.HttpURLConnection
import java.net.Inet4Address import java.net.Inet4Address
import java.net.NetworkInterface import java.net.NetworkInterface
import java.net.URL import java.net.URL
import java.util.Calendar
import kotlin.math.abs
/** /**
* 检查网络连接状态、系统验证结果与互联网实际可达性。 * 检查网络连接状态、系统验证结果、互联网实际可达性与系统时钟偏差。
*/ */
object NetworkChecker { object NetworkChecker {
private const val INTERNET_CHECK_URL = "https://www.baidu.com" private const val INTERNET_CHECK_URL = "https://www.baidu.com"
private const val INTERNET_CHECK_TIMEOUT_MS = 8000 private const val INTERNET_CHECK_TIMEOUT_MS = 8000
/** 允许的设备时钟与服务器 Date 响应头最大偏差(7 天),超过则判定为电视时钟未同步 */
private const val MAX_CLOCK_SKEW_MS = 7L * 24 * 3600 * 1000L
/** 最低合理年份基线(仅在尚未收到任何 HTTP Date 响应头时兜底) */
private const val MIN_VALID_YEAR = 2026
@Volatile
private var lastObservedSkewMs: Long? = null
/** 根据服务器返回的 Date 头毫秒时间戳更新时钟偏差记录 */
fun updateServerDate(serverDateMs: Long) {
if (serverDateMs > 0L) {
lastObservedSkewMs = abs(System.currentTimeMillis() - serverDateMs)
}
}
/**
* 判定电视系统时钟是否明显异常:
* 优先对比最近一次 HTTPS/HTTP 响应 Date 头与本地时间的偏差(不随年份推移腐烂);
* 若尚未发起过网络请求,则用最低合理年份兜底(如电视断电重置回 1970/2015 年)。
*/
fun isSystemClockAbnormal(): Boolean {
val skew = lastObservedSkewMs
if (skew != null) {
return skew > MAX_CLOCK_SKEW_MS
}
return Calendar.getInstance().get(Calendar.YEAR) < MIN_VALID_YEAR
}
/** /**
* 返回链路状态文案:已连接时区分「系统已验证互联网」与「未验证」。 * 返回链路状态文案:已连接时区分「系统已验证互联网」与「未验证」。
@@ -71,31 +100,20 @@ object NetworkChecker {
} }
/** /**
* 实际尝试访问互联网(需在后台线程调用)。 * 实际尝试访问互联网(需在后台线程调用),并顺带利用响应 Date 头校准系统时钟异常状态。
* @return true 表示能连上目标 URL,false 表示超时或失败。 * @return true 表示能连上目标 URL,false 表示超时或失败。
*/ */
fun isInternetReachable(): Boolean { fun isInternetReachable(): Boolean {
return try { return try {
val url = URL(INTERNET_CHECK_URL) val url = URL(INTERNET_CHECK_URL)
val conn = url.openConnection() as? HttpURLConnection ?: return false val conn = url.openConnection() as? HttpURLConnection ?: return false
if (conn is javax.net.ssl.HttpsURLConnection) { PlayerSsl.applyTo(conn, lenient = true)
conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
val trustAll = arrayOf<javax.net.ssl.TrustManager>(
object : javax.net.ssl.X509TrustManager {
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate>? = null
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
}
)
val sc = javax.net.ssl.SSLContext.getInstance("TLS")
sc.init(null, trustAll, java.security.SecureRandom())
conn.sslSocketFactory = sc.socketFactory
}
conn.requestMethod = "HEAD" conn.requestMethod = "HEAD"
conn.connectTimeout = INTERNET_CHECK_TIMEOUT_MS conn.connectTimeout = INTERNET_CHECK_TIMEOUT_MS
conn.readTimeout = INTERNET_CHECK_TIMEOUT_MS conn.readTimeout = INTERNET_CHECK_TIMEOUT_MS
conn.instanceFollowRedirects = true conn.instanceFollowRedirects = true
val code = conn.responseCode val code = conn.responseCode
updateServerDate(conn.date)
conn.disconnect() conn.disconnect()
code in 200..399 code in 200..399
} catch (_: Exception) { } catch (_: Exception) {
@@ -1,9 +1,8 @@
package com.tv.browser package com.tv.browser
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.util.Locale
/** /**
* 视频播放记录项:用于断点续播与继续观看推荐 * 视频播放记录项:用于断点续播与继续观看推荐
@@ -50,51 +49,21 @@ data class PlaybackRecord(
val min = (totalSec / 60) % 60 val min = (totalSec / 60) % 60
val hour = totalSec / 3600 val hour = totalSec / 3600
return if (hour > 0) { return if (hour > 0) {
String.format("%02d:%02d:%02d", hour, min, sec) String.format(Locale.US, "%02d:%02d:%02d", hour, min, sec)
} else { } else {
String.format("%02d:%02d", min, sec) String.format(Locale.US, "%02d:%02d", min, sec)
} }
} }
} }
/** /**
* 播放历史持久化管理(内存缓存 + SharedPreferences 异步落盘,避免播放中高频重复解析 JSON) * 播放历史持久化管理(基于 [JsonListStore] 内存缓存 + SharedPreferences 异步落盘,避免播放中高频重复解析 JSON)
*/ */
class PlaybackHistoryRepository(context: Context) { class PlaybackHistoryRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val appContext = context.applicationContext
fun getAll(): List<PlaybackRecord> { fun getAll(): List<PlaybackRecord> = store.getAll(appContext)
cachedRecords?.let { return it }
synchronized(Companion) {
cachedRecords?.let { return it }
val json = prefs.getString(KEY_RECORDS, null)
val loaded = if (json.isNullOrBlank()) {
emptyList()
} else {
try {
val arr = JSONArray(json)
List(arr.length()) { i ->
val obj = arr.getJSONObject(i)
PlaybackRecord(
url = obj.optString("url", ""),
title = obj.optString("title", ""),
positionMs = obj.optInt("positionMs", 0),
durationMs = obj.optInt("durationMs", 0),
timestamp = obj.optLong("timestamp", 0),
series = obj.optString("series", ""),
seriesTitle = obj.optString("seriesTitle", ""),
episodeName = obj.optString("episodeName", "")
)
}.filter { it.url.isNotBlank() }
} catch (_: Exception) {
emptyList()
}
}
cachedRecords = loaded
return loaded
}
}
/** /**
* 保存播放进度:若播放时间超过 5 秒且未播完,则保存或更新。 * 保存播放进度:若播放时间超过 5 秒且未播完,则保存或更新。
@@ -115,7 +84,7 @@ class PlaybackHistoryRepository(context: Context) {
// 如果已经播到最后 10 秒,视为播放结束,不再存为断点 // 如果已经播到最后 10 秒,视为播放结束,不再存为断点
if (durationMs > 0 && positionMs >= durationMs - 10000) { if (durationMs > 0 && positionMs >= durationMs - 10000) {
save(list) store.save(appContext, list)
return return
} }
@@ -130,8 +99,7 @@ class PlaybackHistoryRepository(context: Context) {
episodeName = episodeName episodeName = episodeName
) )
list.add(0, record) list.add(0, record)
while (list.size > MAX_RECORDS) list.removeAt(list.size - 1) store.save(appContext, list)
save(list)
} }
/** /**
@@ -142,8 +110,7 @@ class PlaybackHistoryRepository(context: Context) {
val list = getAll().toMutableList() val list = getAll().toMutableList()
list.removeAll { it.url == url || it.series == series } list.removeAll { it.url == url || it.series == series }
list.add(0, PlaybackRecord(url, title.ifBlank { seriesTitle }, 0, 0, System.currentTimeMillis(), series, seriesTitle, episodeName)) list.add(0, PlaybackRecord(url, title.ifBlank { seriesTitle }, 0, 0, System.currentTimeMillis(), series, seriesTitle, episodeName))
while (list.size > MAX_RECORDS) list.removeAt(list.size - 1) store.save(appContext, list)
save(list)
} }
/** 获取最近一条未看完的播放记录 */ /** 获取最近一条未看完的播放记录 */
@@ -167,23 +134,22 @@ class PlaybackHistoryRepository(context: Context) {
fun remove(url: String) { fun remove(url: String) {
val list = getAll().toMutableList() val list = getAll().toMutableList()
list.removeAll { it.url == url } list.removeAll { it.url == url }
save(list) store.save(appContext, list)
} }
fun clearAll() { fun clearAll() = store.clear(appContext)
synchronized(Companion) {
cachedRecords = emptyList()
prefs.edit().remove(KEY_RECORDS).apply()
}
}
private fun save(list: List<PlaybackRecord>) { companion object {
val snapshot = list.toList() private const val PREFS_NAME = "tv_browser_playback_history"
synchronized(Companion) { private const val KEY_RECORDS = "records"
cachedRecords = snapshot private const val MAX_RECORDS = 30
val arr = JSONArray()
snapshot.forEach { r -> private val store = JsonListStore<PlaybackRecord>(
arr.put(JSONObject().apply { prefsName = PREFS_NAME,
key = KEY_RECORDS,
maxEntries = MAX_RECORDS,
encode = { r ->
JSONObject().apply {
put("url", r.url) put("url", r.url)
put("title", r.title) put("title", r.title)
put("positionMs", r.positionMs) put("positionMs", r.positionMs)
@@ -192,17 +158,21 @@ class PlaybackHistoryRepository(context: Context) {
if (r.series.isNotBlank()) put("series", r.series) if (r.series.isNotBlank()) put("series", r.series)
if (r.seriesTitle.isNotBlank()) put("seriesTitle", r.seriesTitle) if (r.seriesTitle.isNotBlank()) put("seriesTitle", r.seriesTitle)
if (r.episodeName.isNotBlank()) put("episodeName", r.episodeName) if (r.episodeName.isNotBlank()) put("episodeName", r.episodeName)
}) }
},
decode = { obj ->
val url = obj.optString("url", "")
if (url.isBlank()) null else PlaybackRecord(
url = url,
title = obj.optString("title", ""),
positionMs = obj.optInt("positionMs", 0),
durationMs = obj.optInt("durationMs", 0),
timestamp = obj.optLong("timestamp", 0),
series = obj.optString("series", ""),
seriesTitle = obj.optString("seriesTitle", ""),
episodeName = obj.optString("episodeName", "")
)
} }
prefs.edit().putString(KEY_RECORDS, arr.toString()).apply() )
}
}
companion object {
private const val PREFS_NAME = "tv_browser_playback_history"
private const val KEY_RECORDS = "records"
private const val MAX_RECORDS = 30
@Volatile
private var cachedRecords: List<PlaybackRecord>? = null
} }
} }
@@ -5,11 +5,13 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.Configuration import android.content.res.Configuration
import android.net.TrafficStats
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.Process
import android.util.Log import android.util.Log
import android.util.Rational import android.util.Rational
import android.view.KeyEvent import android.view.KeyEvent
@@ -21,6 +23,7 @@ import android.widget.SeekBar
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
@@ -29,18 +32,18 @@ import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.AspectRatioFrameLayout
import android.net.TrafficStats
import android.os.Process
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import java.util.Calendar
import com.tv.browser.databinding.ActivityPlayerBinding import com.tv.browser.databinding.ActivityPlayerBinding
import com.tv.browser.databinding.ItemEpisodeBinding import com.tv.browser.databinding.ItemEpisodeBinding
import java.io.File
import java.lang.ref.WeakReference
import java.util.Calendar
import java.util.Locale import java.util.Locale
import kotlin.math.ceil import kotlin.math.ceil
import kotlin.math.min import kotlin.math.min
@@ -105,6 +108,7 @@ class PlayerActivity : AppCompatActivity() {
private data class PreloadedEpisode(val index: Int, val pageUrl: String, val source: VideoSource, val time: Long) private data class PreloadedEpisode(val index: Int, val pageUrl: String, val source: VideoSource, val time: Long)
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private val finishCalendar = Calendar.getInstance()
private val speeds = floatArrayOf(1.0f, 1.25f, 1.5f, 2.0f, 0.75f) private val speeds = floatArrayOf(1.0f, 1.25f, 1.5f, 2.0f, 0.75f)
private var currentSpeedIndex = 0 private var currentSpeedIndex = 0
@@ -272,7 +276,7 @@ class PlayerActivity : AppCompatActivity() {
binding.tvStatus.visibility = View.GONE binding.tvStatus.visibility = View.GONE
stallHintShown = false stallHintShown = false
readySinceReResolve = true readySinceReResolve = true
updateProgress() updateProgress(forceUi = true)
maybeSkipIntro() maybeSkipIntro()
failedSources.clear() failedSources.clear()
updatePauseOverlay() updatePauseOverlay()
@@ -393,7 +397,7 @@ class PlayerActivity : AppCompatActivity() {
.setReadTimeoutMs(PlayerTuning.READ_TIMEOUT_MS) .setReadTimeoutMs(PlayerTuning.READ_TIMEOUT_MS)
// 清单命中缓存时直接用网页下载过的内容(一次性 m3u8 链接再请求会失效),分片等仍走网络 // 清单命中缓存时直接用网页下载过的内容(一次性 m3u8 链接再请求会失效),分片等仍走网络
val dataSourceFactory = PlaylistCacheDataSource.Factory(DefaultDataSource.Factory(this, httpFactory)) val dataSourceFactory = PlaylistCacheDataSource.Factory(DefaultDataSource.Factory(this, httpFactory))
Log.d(TAG, "playlist cached=${PlaylistCache.contains(source.url)}") if (BuildConfig.DEBUG) Log.d(TAG, "playlist cached=${PlaylistCache.contains(source.url)}")
val itemBuilder = MediaItem.Builder().setUri(source.url) val itemBuilder = MediaItem.Builder().setUri(source.url)
if (source.isHls) itemBuilder.setMimeType(MimeTypes.APPLICATION_M3U8) if (source.isHls) itemBuilder.setMimeType(MimeTypes.APPLICATION_M3U8)
@@ -420,7 +424,7 @@ class PlayerActivity : AppCompatActivity() {
if (!canPlayNow) p.pause() if (!canPlayNow) p.pause()
if (carried <= 0 && record != null && startPos > 0) showFeedback("已自动续播至 ${record.formatPosition()}") if (carried <= 0 && record != null && startPos > 0) showFeedback("已自动续播至 ${record.formatPosition()}")
Log.d(TAG, "play: ${source.url} hls=${source.isHls} canPlayNow=$canPlayNow headers=${source.headers.keys}") if (BuildConfig.DEBUG) Log.d(TAG, "play: ${source.url} hls=${source.isHls} canPlayNow=$canPlayNow headers=${source.headers.keys}")
lastSavedSec = 0 lastSavedSec = 0
suppressSave = false suppressSave = false
introChecked = false introChecked = false
@@ -563,7 +567,7 @@ class PlayerActivity : AppCompatActivity() {
if (pre != null && pre.index == index && pre.pageUrl == ep.url && if (pre != null && pre.index == index && pre.pageUrl == ep.url &&
System.currentTimeMillis() - pre.time < PRELOAD_VALID_MS System.currentTimeMillis() - pre.time < PRELOAD_VALID_MS
) { ) {
Log.d(TAG, "play preloaded episode #$index ${ep.name}") if (BuildConfig.DEBUG) Log.d(TAG, "play preloaded episode #$index ${ep.name}")
player?.stop() player?.stop()
currentPageUrl = ep.url currentPageUrl = ep.url
updateEpisodeIndex() updateEpisodeIndex()
@@ -654,7 +658,7 @@ class PlayerActivity : AppCompatActivity() {
binding.tvVideoTitle.text = videoTitle binding.tvVideoTitle.text = videoTitle
updateEpisodeUi() updateEpisodeUi()
val status = if (auto) "线路「$fromName」$reason,自动切换到「${target.name}」…" else "正在切换到「${target.name}」…" val status = if (auto) "线路「$fromName」$reason,自动切换到「${target.name}」…" else "正在切换到「${target.name}」…"
Log.d(TAG, "switch source -> #$index ${target.name} ep=${ep.name} auto=$auto") if (BuildConfig.DEBUG) Log.d(TAG, "switch source -> #$index ${target.name} ep=${ep.name} auto=$auto")
resolveAndPlay(ep.url, needSiteInfo = false, status = status) resolveAndPlay(ep.url, needSiteInfo = false, status = status)
return true return true
} }
@@ -689,13 +693,13 @@ class PlayerActivity : AppCompatActivity() {
preloadingIndex = nextIndex preloadingIndex = nextIndex
val r = preloadResolver ?: VideoResolver(this, binding.resolverHost, BrowserActivity.currentUserAgent(this)) val r = preloadResolver ?: VideoResolver(this, binding.resolverHost, BrowserActivity.currentUserAgent(this))
.also { preloadResolver = it } .also { preloadResolver = it }
Log.d(TAG, "preload next episode #$nextIndex ${ep.name}") if (BuildConfig.DEBUG) Log.d(TAG, "preload next episode #$nextIndex ${ep.name}")
r.resolve(ep.url, false, object : VideoResolver.Callback { r.resolve(ep.url, false, object : VideoResolver.Callback {
override fun onResolved(source: VideoSource, siteInfo: SiteInfo?) { override fun onResolved(source: VideoSource, siteInfo: SiteInfo?) {
if (preloadingIndex != nextIndex) return if (preloadingIndex != nextIndex) return
preloadingIndex = -1 preloadingIndex = -1
preloaded = PreloadedEpisode(nextIndex, ep.url, source, System.currentTimeMillis()) preloaded = PreloadedEpisode(nextIndex, ep.url, source, System.currentTimeMillis())
Log.d(TAG, "preloaded #$nextIndex: ${source.url}") if (BuildConfig.DEBUG) Log.d(TAG, "preloaded #$nextIndex: ${source.url}")
} }
override fun onFailed(reason: String) { override fun onFailed(reason: String) {
@@ -733,7 +737,7 @@ class PlayerActivity : AppCompatActivity() {
if (outro <= 0 || outro >= duration) return if (outro <= 0 || outro >= duration) return
if (current >= duration - outro) { if (current >= duration - outro) {
outroTriggered = true outroTriggered = true
Log.d(TAG, "outro reached at ${formatTime(current)}, play next") if (BuildConfig.DEBUG) Log.d(TAG, "outro reached at ${formatTime(current)}, play next")
showFeedback("已跳过片尾") showFeedback("已跳过片尾")
onPlaybackEnded() onPlaybackEnded()
} }
@@ -843,7 +847,7 @@ class PlayerActivity : AppCompatActivity() {
appendLine("🎬 视频标题:$videoTitle") appendLine("🎬 视频标题:$videoTitle")
if (seriesTitle.isNotBlank()) appendLine("📺 剧集归属:$seriesTitle") if (seriesTitle.isNotBlank()) appendLine("📺 剧集归属:$seriesTitle")
appendLine("⏱️ 播放进度:${formatTime(cur)} / ${if (dur > 0) formatTime(dur) else "--:--"}") appendLine("⏱️ 播放进度:${formatTime(cur)} / ${if (dur > 0) formatTime(dur) else "--:--"}")
appendLine("🌐 实时网速:$currentSpeedStr") appendLine("🌐 实时网速:${updateNetworkSpeed()}")
appendLine("📦 已缓冲时长:${bufferedAheadSec} 秒 (${p?.bufferedPercentage ?: 0}%)") appendLine("📦 已缓冲时长:${bufferedAheadSec} 秒 (${p?.bufferedPercentage ?: 0}%)")
val down = diagnostics.downloadBytesPerSec val down = diagnostics.downloadBytesPerSec
val need = diagnostics.mediaBytesPerSec val need = diagnostics.mediaBytesPerSec
@@ -1052,7 +1056,7 @@ class PlayerActivity : AppCompatActivity() {
p.seekTo(target) p.seekTo(target)
val sign = if (offsetMs > 0) "+" else "" val sign = if (offsetMs > 0) "+" else ""
showFeedback("$sign${offsetMs / 1000}s (${formatTime(target)})") showFeedback("$sign${offsetMs / 1000}s (${formatTime(target)})")
updateProgress() updateProgress(forceUi = true)
} }
/** 长按方向键加速:10s → 30s → 60s */ /** 长按方向键加速:10s → 30s → 60s */
@@ -1083,7 +1087,9 @@ class PlayerActivity : AppCompatActivity() {
private fun isOsdVisible() = binding.playerOsd.visibility == View.VISIBLE private fun isOsdVisible() = binding.playerOsd.visibility == View.VISIBLE
private fun showOsdTemporarily(focusPlay: Boolean = false) { private fun showOsdTemporarily(focusPlay: Boolean = false) {
val wasHidden = binding.playerOsd.visibility != View.VISIBLE
binding.playerOsd.visibility = View.VISIBLE binding.playerOsd.visibility = View.VISIBLE
if (wasHidden) updateProgress(forceUi = true)
if (focusPlay) binding.btnPlayPause.requestFocus() if (focusPlay) binding.btnPlayPause.requestFocus()
mainHandler.removeCallbacks(hideOsdRunnable) mainHandler.removeCallbacks(hideOsdRunnable)
// 正在播放时 5 秒后自动隐藏;暂停时控制栏常驻显示,方便查看进度与操作 // 正在播放时 5 秒后自动隐藏;暂停时控制栏常驻显示,方便查看进度与操作
@@ -1129,18 +1135,11 @@ class PlayerActivity : AppCompatActivity() {
return currentSpeedStr return currentSpeedStr
} }
private fun updateProgress() { private fun updateProgress(forceUi: Boolean = false) {
val p = player ?: return val p = player ?: return
val current = p.currentPosition.coerceAtLeast(0L) val current = p.currentPosition.coerceAtLeast(0L)
val duration = p.duration val duration = p.duration
val bufferedPos = p.bufferedPosition.coerceAtLeast(0L)
val bufferedAheadSec = ((bufferedPos - current).coerceAtLeast(0L)) / 1000L
// 缓冲够了播放器会暂停下载,这时流量为 0 是正常的,不要显示成「网速 0」
val rawSpeed = updateNetworkSpeed()
val speed = buildString {
append(if (!p.isLoading && bufferedAheadSec >= 10) "缓冲充足,暂停下载" else rawSpeed)
diagnostics.summary()?.let { append("(").append(it).append(")") }
}
// 播着播着卡住:给出原因(片源速度跟不上 / 分片下载失败),而不是只转圈 // 播着播着卡住:给出原因(片源速度跟不上 / 分片下载失败),而不是只转圈
if (p.playbackState == Player.STATE_BUFFERING && diagnostics.isStallAfterPlaying()) { if (p.playbackState == Player.STATE_BUFFERING && diagnostics.isStallAfterPlaying()) {
diagnostics.stallHint()?.let { diagnostics.stallHint()?.let {
@@ -1152,26 +1151,41 @@ class PlayerActivity : AppCompatActivity() {
binding.tvStatus.visibility = View.GONE binding.tvStatus.visibility = View.GONE
} }
binding.tvCurrentTime.text = formatTime(current) // 仅当控制栏可见(或显式刷新)时才计算网速与格式化 UI 文案,降低主线程每 500ms 的开销
if (duration != C.TIME_UNSET && duration > 0) { if (forceUi || isOsdVisible()) {
binding.tvTotalTime.text = formatTime(duration) val bufferedPos = p.bufferedPosition.coerceAtLeast(0L)
binding.playerSeekBar.progress = ((current * 1000) / duration).toInt() val bufferedAheadSec = ((bufferedPos - current).coerceAtLeast(0L)) / 1000L
val bufferPercent = p.bufferedPercentage val rawSpeed = updateNetworkSpeed()
val remainingMs = (duration - current).coerceAtLeast(0L) val speed = buildString {
val remainingStr = formatTime(remainingMs) append(if (!p.isLoading && bufferedAheadSec >= 10) "缓冲充足,暂停下载" else rawSpeed)
val finishCal = Calendar.getInstance().apply { add(Calendar.MILLISECOND, remainingMs.toInt()) } diagnostics.summary()?.let { append("(").append(it).append(")") }
val finishTimeStr = String.format(Locale.CHINA, "%02d:%02d", finishCal.get(Calendar.HOUR_OF_DAY), finishCal.get(Calendar.MINUTE)) }
binding.tvPlaybackInfo.text = "网速: $speed · 缓冲: ${bufferedAheadSec}秒 ($bufferPercent%) · 剩余: $remainingStr · 预计 $finishTimeStr 播完" binding.tvCurrentTime.text = formatTime(current)
} else { if (duration != C.TIME_UNSET && duration > 0) {
binding.tvTotalTime.text = "--:--" binding.tvTotalTime.text = formatTime(duration)
binding.tvPlaybackInfo.text = "网速: $speed · 缓冲: ${bufferedAheadSec}秒 · 已播放: ${formatTime(current)}" binding.playerSeekBar.progress = ((current * 1000) / duration).toInt()
val bufferPercent = p.bufferedPercentage
val remainingMs = (duration - current).coerceAtLeast(0L)
val remainingStr = formatTime(remainingMs)
finishCalendar.timeInMillis = System.currentTimeMillis() + remainingMs
val finishTimeStr = String.format(
Locale.CHINA,
"%02d:%02d",
finishCalendar.get(Calendar.HOUR_OF_DAY),
finishCalendar.get(Calendar.MINUTE)
)
binding.tvPlaybackInfo.text = "网速: $speed · 缓冲: ${bufferedAheadSec}秒 ($bufferPercent%) · 剩余: $remainingStr · 预计 $finishTimeStr 播完"
} else {
binding.tvTotalTime.text = "--:--"
binding.tvPlaybackInfo.text = "网速: $speed · 缓冲: ${bufferedAheadSec}秒 · 已播放: ${formatTime(current)}"
}
} }
if (p.playbackState == Player.STATE_READY && current > 0) lastKnownPos = current if (p.playbackState == Player.STATE_READY && current > 0) lastKnownPos = current
// 每隔 5 秒自动持久化保存一次播放断点进度 // 每隔 15 秒自动持久化保存一次播放断点进度(暂停/切集/退出时立即保存)
val currentSec = current / 1000 val currentSec = current / 1000
if (currentSec >= 5 && currentSec - lastSavedSec >= 5) { if (currentSec >= 5 && currentSec - lastSavedSec >= 15) {
lastSavedSec = currentSec lastSavedSec = currentSec
saveCurrentProgress() saveCurrentProgress()
} }
@@ -1249,7 +1263,7 @@ class PlayerActivity : AppCompatActivity() {
) { ) {
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
if (isInPictureInPictureMode) { if (isInPictureInPictureMode) {
pipInstanceRef = java.lang.ref.WeakReference(this) pipInstanceRef = WeakReference(this)
pipEnteredAt = android.os.SystemClock.uptimeMillis() pipEnteredAt = android.os.SystemClock.uptimeMillis()
hideOsd() hideOsd()
binding.tvFeedback.visibility = View.GONE binding.tvFeedback.visibility = View.GONE
@@ -1315,7 +1329,7 @@ class PlayerActivity : AppCompatActivity() {
} }
} }
/** 调用系统或第三方专业播放器(Kodi / MX Player / VLC 等),附带请求头 */ /** 调用系统或第三方专业播放器(Kodi / MX Player / VLC 等),附带请求头;对本地文件自动转换 FileProvider URI */
private fun launchExternalPlayer() { private fun launchExternalPlayer() {
val source = currentSource val source = currentSource
if (source == null) { if (source == null) {
@@ -1323,14 +1337,24 @@ class PlayerActivity : AppCompatActivity() {
return return
} }
try { try {
var targetUri = Uri.parse(source.url)
if (targetUri.scheme == "file") {
val path = targetUri.path
if (!path.isNullOrBlank()) {
targetUri = FileProvider.getUriForFile(this, "$packageName.fileprovider", File(path))
}
}
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(Uri.parse(source.url), if (source.isHls) "application/x-mpegURL" else "video/*") setDataAndType(targetUri, if (source.isHls) "application/x-mpegURL" else "video/*")
putExtra("title", videoTitle) putExtra("title", videoTitle)
// MX Player 等支持的请求头格式:[key1, value1, key2, value2, ...] // MX Player 等支持的请求头格式:[key1, value1, key2, value2, ...]
if (source.headers.isNotEmpty()) { if (source.headers.isNotEmpty()) {
putExtra("headers", source.headers.flatMap { listOf(it.key, it.value) }.toTypedArray()) putExtra("headers", source.headers.flatMap { listOf(it.key, it.value) }.toTypedArray())
} }
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (targetUri.scheme == "content") {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
} }
startActivity(Intent.createChooser(intent, "选择外部播放器")) startActivity(Intent.createChooser(intent, "选择外部播放器"))
} catch (e: Exception) { } catch (e: Exception) {
@@ -1339,6 +1363,51 @@ class PlayerActivity : AppCompatActivity() {
} }
} }
/** 局域网手机遥控:当 PlayerActivity 在前台时响应触控板轻击确认 */
internal fun handleRemoteClick(): Boolean {
val focused = currentFocus
if ((isOsdVisible() || isEpisodePanelVisible()) && focused != null && focused != binding.playerRoot) {
focused.performClick()
return true
}
togglePlayPause()
return true
}
/** 局域网手机遥控:当 PlayerActivity 在前台时响应手机遥控器按键 */
internal fun handleRemoteKey(keyCode: String): Boolean {
when (keyCode) {
"back" -> onBackPressed()
"menu" -> if (isEpisodePanelVisible()) hideEpisodePanel() else if (isOsdVisible()) hideOsd() else showOsdTemporarily(focusPlay = true)
"fullscreen" -> toggleAspectRatio()
"refresh" -> {
val page = currentPageUrl
if (page != null) {
if (lastKnownPos > 5000) pendingStartPos = lastKnownPos
resolveAndPlay(page, needSiteInfo = false, status = "正在重新加载当前视频…")
} else {
currentSource?.let {
if (lastKnownPos > 5000) pendingStartPos = lastKnownPos
playSource(it)
}
}
}
"external_play" -> launchExternalPlayer()
"native_play" -> togglePlayPause()
"dpad_left" -> dispatchKeyPair(KeyEvent.KEYCODE_DPAD_LEFT)
"dpad_right" -> dispatchKeyPair(KeyEvent.KEYCODE_DPAD_RIGHT)
"dpad_up" -> dispatchKeyPair(KeyEvent.KEYCODE_DPAD_UP)
"dpad_down" -> dispatchKeyPair(KeyEvent.KEYCODE_DPAD_DOWN)
else -> return false
}
return true
}
private fun dispatchKeyPair(keyCode: Int) {
dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode))
}
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
// 遥控器按键 // 遥控器按键
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
@@ -1524,7 +1593,7 @@ class PlayerActivity : AppCompatActivity() {
isSeeking = false isSeeking = false
if (ev.actionMasked == android.view.MotionEvent.ACTION_UP) { if (ev.actionMasked == android.view.MotionEvent.ACTION_UP) {
player?.seekTo(gestureTargetPos) player?.seekTo(gestureTargetPos)
updateProgress() updateProgress(forceUi = true)
} }
return true return true
} else if (mode == 2 || mode == 3) { } else if (mode == 2 || mode == 3) {
@@ -1556,6 +1625,7 @@ class PlayerActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
activeInstance = WeakReference(this)
isInBackground = false isInBackground = false
hideSystemUI() hideSystemUI()
if (currentSource != null) { if (currentSource != null) {
@@ -1568,6 +1638,7 @@ class PlayerActivity : AppCompatActivity() {
super.onPause() super.onPause()
val inPip = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) isInPictureInPictureMode else false val inPip = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) isInPictureInPictureMode else false
if (!inPip) { if (!inPip) {
if (activeInstance?.get() === this) activeInstance = null
isInBackground = true isInBackground = true
pauseForBackground() pauseForBackground()
} else { } else {
@@ -1577,6 +1648,7 @@ class PlayerActivity : AppCompatActivity() {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
if (activeInstance?.get() === this) activeInstance = null
isInBackground = true isInBackground = true
mainHandler.removeCallbacks(updateProgressRunnable) mainHandler.removeCallbacks(updateProgressRunnable)
cancelPreload() cancelPreload()
@@ -1585,7 +1657,9 @@ class PlayerActivity : AppCompatActivity() {
} }
override fun onDestroy() { override fun onDestroy() {
if (activeInstance?.get() === this) activeInstance = null
if (pipInstanceRef?.get() === this) pipInstanceRef = null if (pipInstanceRef?.get() === this) pipInstanceRef = null
PlayerSsl.restoreSystemDefaults()
saveCurrentProgress() saveCurrentProgress()
mainHandler.removeCallbacksAndMessages(null) mainHandler.removeCallbacksAndMessages(null)
resolver?.destroy() resolver?.destroy()
@@ -1600,6 +1674,10 @@ class PlayerActivity : AppCompatActivity() {
companion object { companion object {
private const val TAG = "PlayerActivity" private const val TAG = "PlayerActivity"
/** 当前处于前台活动状态的 PlayerActivity,用于接收局域网手机遥控按键 */
@Volatile
internal var activeInstance: WeakReference<PlayerActivity>? = null
/** 选集格子目标宽度(dp),列数 = 屏幕宽度 / 该值,限制在 4~10 列 */ /** 选集格子目标宽度(dp),列数 = 屏幕宽度 / 该值,限制在 4~10 列 */
private const val EPISODE_CELL_DP = 110 private const val EPISODE_CELL_DP = 110
/** 距结尾多久开始预解析下一集 */ /** 距结尾多久开始预解析下一集 */
@@ -1645,7 +1723,7 @@ class PlayerActivity : AppCompatActivity() {
private const val PIP_RESTORE_GUARD_MS = 3000L private const val PIP_RESTORE_GUARD_MS = 3000L
/** 当前处于画中画小窗的播放器实例 */ /** 当前处于画中画小窗的播放器实例 */
private var pipInstanceRef: java.lang.ref.WeakReference<PlayerActivity>? = null private var pipInstanceRef: WeakReference<PlayerActivity>? = null
private var pipEnteredAt = 0L private var pipEnteredAt = 0L
/** /**
@@ -1677,8 +1755,8 @@ class PlayerActivity : AppCompatActivity() {
/** 加密流(央视网等)原生播放会花屏:拒绝启动并提示 */ /** 加密流(央视网等)原生播放会花屏:拒绝启动并提示 */
private fun rejectWebOnly(context: Context, url: String): Boolean { private fun rejectWebOnly(context: Context, url: String): Boolean {
if (!VideoSnifferHelper.isWebOnlyStream(url)) return false if (!VideoSnifferHelper.isWebOnlyStream(url)) return false
Log.d(TAG, "reject web-only stream: $url") if (BuildConfig.DEBUG) Log.d(TAG, "reject web-only stream: $url")
android.widget.Toast.makeText(context, VideoSnifferHelper.WEB_ONLY_MESSAGE, android.widget.Toast.LENGTH_LONG).show() Toast.makeText(context, VideoSnifferHelper.WEB_ONLY_MESSAGE, Toast.LENGTH_LONG).show()
return true return true
} }
+54 -19
View File
@@ -2,32 +2,61 @@ package com.tv.browser
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.util.Log import android.util.Log
import java.net.HttpURLConnection
import java.security.SecureRandom
import java.security.cert.CertPathValidatorException import java.security.cert.CertPathValidatorException
import java.security.cert.CertificateException import java.security.cert.CertificateException
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.SSLException import javax.net.ssl.SSLException
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
/** /**
* 原生播放器的 SSL 兼容处理。 * 原生播放器与内部网络请求的 SSL 兼容管理。
* *
* 网页里 WebView 遇到证书错误会继续加载(见 onReceivedSslError),但 ExoPlayer 走的是系统 HttpURLConnection, * 1. 保存进程启动时的系统默认 SSLSocketFactory 与 HostnameVerifier,确保宽松模式可随时回滚恢复,不再永久污染全局。
* 证书过期 / 旧电视缺少新根证书(如 Let's Encrypt ISRG Root X1)/ 电视时间不准时会直接报 * 2. 提供单连接级 [applyTo] 方法,供 [VodSearchHelper] 与 [NetworkChecker] 按需独立配置,避免重复定义 TrustManager。
* ERROR_CODE_IO_NETWORK_CONNECTION_FAILED。这里在检测到 SSL 类错误后开启"宽松模式",与网页行为保持一致。 * 3. 播放器遇到证书错误时按需调用 [enableLenient] 并在切源/退出播放器时调用 [restoreSystemDefaults] 恢复系统默认校验。
*
* 注意:HttpsURLConnection 的默认工厂是进程级的,开启后本进程内的 HttpURLConnection 请求都不再校验证书
* (WebView 不受影响)。只在播放器确实遇到 SSL 错误时才开启。
*/ */
object PlayerSsl { object PlayerSsl {
private const val TAG = "PlayerSsl" private const val TAG = "PlayerSsl"
/** 保存系统原始默认 SSL 工厂与域名校验器,支持随时回滚 */
private val systemSslSocketFactory: SSLSocketFactory = HttpsURLConnection.getDefaultSSLSocketFactory()
private val systemHostnameVerifier: HostnameVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
val lenientSslSocketFactory: SSLSocketFactory by lazy {
val trustAll = @SuppressLint("CustomX509TrustManager") object : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
@SuppressLint("TrustAllX509TrustManager")
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
SSLContext.getInstance("TLS").apply {
init(null, arrayOf<TrustManager>(trustAll), SecureRandom())
}.socketFactory
}
@SuppressLint("BadHostnameVerifier")
val lenientHostnameVerifier: HostnameVerifier = HostnameVerifier { _, _ -> true }
@Volatile @Volatile
var lenient: Boolean = false var lenient: Boolean = false
private set private set
/** 为单个 HttpURLConnection 显式指定严格或宽松 SSL 配置(不受全局默认状态影响) */
fun applyTo(conn: HttpURLConnection, lenient: Boolean) {
if (conn is HttpsURLConnection) {
conn.sslSocketFactory = if (lenient) lenientSslSocketFactory else systemSslSocketFactory
conn.hostnameVerifier = if (lenient) lenientHostnameVerifier else systemHostnameVerifier
}
}
/** 错误链中是否包含证书 / SSL 握手问题 */ /** 错误链中是否包含证书 / SSL 握手问题 */
fun isSslError(error: Throwable?): Boolean { fun isSslError(error: Throwable?): Boolean {
var e = error var e = error
@@ -54,27 +83,33 @@ object PlayerSsl {
return if (msg.isBlank()) e.javaClass.simpleName else "${e.javaClass.simpleName}: $msg" return if (msg.isBlank()) e.javaClass.simpleName else "${e.javaClass.simpleName}: $msg"
} }
/** 开启宽松模式:信任所有证书、不校验主机名。返回 true 表示本次新开启 */ /** 为当前播放会话开启宽松模式;返回 true 表示本次新开启 */
@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager", "BadHostnameVerifier")
@Synchronized @Synchronized
fun enableLenient(): Boolean { fun enableLenient(): Boolean {
if (lenient) return false if (lenient) return false
return try { return try {
val trustAll = object : X509TrustManager { HttpsURLConnection.setDefaultSSLSocketFactory(lenientSslSocketFactory)
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {} HttpsURLConnection.setDefaultHostnameVerifier(lenientHostnameVerifier)
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
val ctx = SSLContext.getInstance("TLS")
ctx.init(null, arrayOf<TrustManager>(trustAll), java.security.SecureRandom())
HttpsURLConnection.setDefaultSSLSocketFactory(ctx.socketFactory)
HttpsURLConnection.setDefaultHostnameVerifier { _, _ -> true }
lenient = true lenient = true
Log.w(TAG, "lenient SSL enabled for native player") Log.w(TAG, "lenient SSL enabled for current playback session")
true true
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "enableLenient failed", e) Log.e(TAG, "enableLenient failed", e)
false false
} }
} }
/** 恢复系统默认严格 SSL 校验(播放器切源或退出时调用,防止进程级永久降级) */
@Synchronized
fun restoreSystemDefaults() {
if (!lenient) return
try {
HttpsURLConnection.setDefaultSSLSocketFactory(systemSslSocketFactory)
HttpsURLConnection.setDefaultHostnameVerifier(systemHostnameVerifier)
lenient = false
Log.d(TAG, "system default SSL verification restored")
} catch (e: Exception) {
Log.e(TAG, "restoreSystemDefaults failed", e)
}
}
} }
@@ -49,16 +49,15 @@ object PlaylistCache {
fun isReady(url: String): Boolean { fun isReady(url: String): Boolean {
val entry = map[url] ?: return false val entry = map[url] ?: return false
if (!entry.isMaster) return true if (!entry.isMaster) return true
val base = Uri.parse(entry.finalUrl)
return entry.text.lineSequence() return entry.text.lineSequence()
.map { it.trim() } .map { it.trim() }
.filter { it.isNotEmpty() && !it.startsWith("#") } .filter { it.isNotEmpty() && !it.startsWith("#") }
.any { line -> map.containsKey(resolve(base, line)) } .any { line -> map.containsKey(resolve(entry.finalUrl, line)) }
} }
private fun resolve(base: Uri, ref: String): String = private fun resolve(baseUrl: String, ref: String): String =
try { try {
java.net.URI(base.toString()).resolve(ref).toString() java.net.URI(baseUrl).resolve(ref).toString()
} catch (_: Exception) { } catch (_: Exception) {
ref ref
} }
@@ -75,6 +74,9 @@ object PlaylistCache {
if (!entry.isMaster && !entry.isVod) map.remove(url) if (!entry.isMaster && !entry.isVod) map.remove(url)
return entry return entry
} }
@Synchronized
fun clear() = map.clear()
} }
/** /**
@@ -17,5 +17,5 @@ interface RemoteControlCallback {
/** 推送网址:在电视端立即打开并加载该网址 */ /** 推送网址:在电视端立即打开并加载该网址 */
fun onRemoteOpenUrl(url: String) fun onRemoteOpenUrl(url: String)
/** 局域网快传:手机端向电视上传文件(APK 或多媒体文件) */ /** 局域网快传:手机端向电视上传文件(APK 或多媒体文件) */
fun onRemoteFileUploaded(file: java.io.File, filename: String) fun onRemoteFileUploaded(file: java.io.File, filename: String, clientIp: String = "")
} }
@@ -19,8 +19,6 @@ import android.widget.TextView
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.QRCodeWriter
import java.net.Inet4Address
import java.net.NetworkInterface
import java.util.regex.Pattern import java.util.regex.Pattern
/** /**
@@ -189,28 +187,6 @@ class RemoteControlDialog : DialogFragment() {
return ipPattern.matcher(s).matches() return ipPattern.matcher(s).matches()
} }
private fun getLocalIpAddress(): String? {
try {
val interfaces = NetworkInterface.getNetworkInterfaces()?.toList() ?: return null
val sorted = interfaces.sortedByDescending { ni ->
when {
ni.name.startsWith("wlan", ignoreCase = true) -> 2
ni.name.startsWith("eth", ignoreCase = true) -> 1
else -> 0
}
}
for (ni in sorted) {
if (ni.isLoopback || !ni.isUp) continue
for (addr in ni.inetAddresses) {
if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.hostAddress.orEmpty().startsWith("127.")) {
return addr.hostAddress
}
}
}
} catch (_: Exception) { }
return null
}
private fun generateQrBitmap(content: String, sizePx: Int): Bitmap { private fun generateQrBitmap(content: String, sizePx: Int): Bitmap {
val writer = QRCodeWriter() val writer = QRCodeWriter()
val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, sizePx, sizePx) val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, sizePx, sizePx)
@@ -150,51 +150,62 @@ class RemoteControlHelper(
} }
/** 处理手机快传上传的文件(APK 安装包、视频文件或普通文件) */ /** 处理手机快传上传的文件(APK 安装包、视频文件或普通文件) */
fun handleFileUploaded(file: File, filename: String) { fun handleFileUploaded(file: File, filename: String, clientIp: String = "") {
val targetActivity = PlayerActivity.activeInstance?.get()?.takeIf { !it.isFinishing && !it.isDestroyed } ?: activity
val lower = filename.lowercase() val lower = filename.lowercase()
if (lower.endsWith(".apk")) { if (lower.endsWith(".apk")) {
promptInstallApk(file, filename) promptInstallApk(targetActivity, file, filename, clientIp)
} else if (lower.endsWith(".mp4") || lower.endsWith(".m3u8") || lower.endsWith(".mkv") || lower.endsWith(".flv")) { } else if (lower.endsWith(".mp4") || lower.endsWith(".m3u8") || lower.endsWith(".mkv") || lower.endsWith(".flv") || lower.endsWith(".ts") || lower.endsWith(".webm")) {
AlertDialog.Builder(activity) val contentUri = try {
FileProvider.getUriForFile(
targetActivity,
"${targetActivity.packageName}.fileprovider",
file
).toString()
} catch (_: Exception) {
Uri.fromFile(file).toString()
}
AlertDialog.Builder(targetActivity)
.setTitle("🎬 收到手机快传视频") .setTitle("🎬 收到手机快传视频")
.setMessage("文件:$filename\n大小:${file.length() / 1024 / 1024}MB\n是否立即全屏播放?") .setMessage("文件:$filename\n大小:${file.length() / 1024 / 1024}MB\n是否立即全屏播放?")
.setPositiveButton("立即播放") { _, _ -> .setPositiveButton("立即播放") { _, _ ->
PlayerActivity.start(activity, Uri.fromFile(file).toString(), filename) PlayerActivity.start(targetActivity, contentUri, filename)
} }
.setNegativeButton("稍后", null) .setNegativeButton("稍后", null)
.show() .show()
} else { } else {
Toast.makeText(activity, "收到手机文件:$filename (已存入下载目录)", Toast.LENGTH_LONG).show() Toast.makeText(targetActivity, "收到手机文件:$filename (已存入快传目录)", Toast.LENGTH_LONG).show()
} }
} }
private fun promptInstallApk(file: File, filename: String) { private fun promptInstallApk(hostActivity: AppCompatActivity, file: File, filename: String, clientIp: String) {
try { try {
val uri = FileProvider.getUriForFile( val uri = FileProvider.getUriForFile(
activity, hostActivity,
"${activity.packageName}.fileprovider", "${hostActivity.packageName}.fileprovider",
file file
) )
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive") setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
} }
AlertDialog.Builder(activity) val senderInfo = if (clientIp.isNotBlank()) "来源设备:$clientIp\n" else ""
.setTitle("📱 收到手机快传 APK 安装包") AlertDialog.Builder(hostActivity)
.setMessage("应用文件:$filename\n大小:${file.length() / 1024 / 1024}MB\n是否立即安装?") .setTitle("📱 收到局域网快传 APK 安装包")
.setPositiveButton("立即安装") { _, _ -> .setMessage("${senderInfo}应用文件:$filename\n文件大小:${file.length() / 1024 / 1024}MB\n\n⚠️ 请确认该安装包为本人操作发送后再继续安装。")
.setPositiveButton("确认并安装") { _, _ ->
try { try {
activity.startActivity(intent) hostActivity.startActivity(intent)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to launch package installer", e) Log.e(TAG, "Failed to launch package installer", e)
Toast.makeText(activity, "打开安装器失败: ${e.message}", Toast.LENGTH_LONG).show() Toast.makeText(hostActivity, "打开安装器失败: ${e.message}", Toast.LENGTH_LONG).show()
} }
} }
.setNegativeButton("稍后", null) .setNegativeButton("取消", null)
.show() .show()
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "FileProvider error for APK", e) Log.e(TAG, "FileProvider error for APK", e)
Toast.makeText(activity, "解析安装包异常: ${e.message}", Toast.LENGTH_SHORT).show() Toast.makeText(hostActivity, "解析安装包异常: ${e.message}", Toast.LENGTH_SHORT).show()
} }
} }
@@ -9,6 +9,7 @@ import org.json.JSONObject
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.UUID
import kotlin.jvm.Volatile import kotlin.jvm.Volatile
private const val TAG = "RemoteControlServer" private const val TAG = "RemoteControlServer"
@@ -16,6 +17,11 @@ private const val PORT = 8766
/** /**
* 本地 HTTP 服务:提供遥控网页,接收 POST /cmd 的 JSON 指令并回调到主线程。 * 本地 HTTP 服务:提供遥控网页,接收 POST /cmd 的 JSON 指令并回调到主线程。
*
* 安全策略:
* 1. 不返回 Access-Control-Allow-Origin: *,阻止电视浏览器中打开的第三方恶意网页通过跨站请求调用本地端口(CSRF)
* 2. 每次服务启动生成随机 Session Token 并注入同源 remote.html,所有 POST /cmd 与 /upload 均校验 X-TV-Token 请求头
* 3. 上传文件限制扩展名白名单,落盘路径统一使用 getUploadDir(context)
*/ */
class RemoteControlServer( class RemoteControlServer(
private val context: Context, private val context: Context,
@@ -26,6 +32,9 @@ class RemoteControlServer(
@Volatile @Volatile
private var running = false private var running = false
/** 每次服务实例生成独立的随机同源会话令牌 */
private val sessionToken: String = UUID.randomUUID().toString().replace("-", "")
/** 服务是否仍在运行(用于状态检测;与 NanoHTTPD.isAlive 区分,避免 override final) */ /** 服务是否仍在运行(用于状态检测;与 NanoHTTPD.isAlive 区分,避免 override final) */
fun isServerRunning(): Boolean = running fun isServerRunning(): Boolean = running
@@ -45,6 +54,7 @@ class RemoteControlServer(
private val htmlPage: String by lazy { private val htmlPage: String by lazy {
try { try {
context.assets.open("remote.html").bufferedReader().use { it.readText() } context.assets.open("remote.html").bufferedReader().use { it.readText() }
.replace("__TV_SESSION_TOKEN__", sessionToken)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Failed to load remote.html", e) Log.e(TAG, "Failed to load remote.html", e)
"<html><body>Failed to load remote page.</body></html>" "<html><body>Failed to load remote page.</body></html>"
@@ -53,43 +63,44 @@ class RemoteControlServer(
val port: Int get() = PORT val port: Int get() = PORT
private fun newCorsResponse( private fun newSameOriginResponse(
status: NanoHTTPD.Response.IStatus, status: NanoHTTPD.Response.IStatus,
mimeType: String, mimeType: String,
data: ByteArray data: ByteArray
): NanoHTTPD.Response { ): NanoHTTPD.Response {
return newFixedLengthResponse(status, mimeType, ByteArrayInputStream(data), data.size.toLong()).apply { return newFixedLengthResponse(status, mimeType, ByteArrayInputStream(data), data.size.toLong()).apply {
addHeader("Access-Control-Allow-Origin", "*") addHeader("X-Content-Type-Options", "nosniff")
addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") addHeader("Cache-Control", "no-store")
addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
addHeader("Access-Control-Max-Age", "86400")
} }
} }
private fun isAuthorized(session: IHTTPSession): Boolean {
val headerToken = session.headers["x-tv-token"]?.trim()
return !headerToken.isNullOrEmpty() && headerToken == sessionToken
}
override fun serve(session: IHTTPSession): NanoHTTPD.Response { override fun serve(session: IHTTPSession): NanoHTTPD.Response {
val uri = session.uri val uri = session.uri
if (session.method == NanoHTTPD.Method.OPTIONS) {
return newCorsResponse(
NanoHTTPD.Response.Status.OK,
"text/plain",
ByteArray(0)
)
}
if (session.method == NanoHTTPD.Method.GET && (uri == "/" || uri == "/remote" || uri.isEmpty())) { if (session.method == NanoHTTPD.Method.GET && (uri == "/" || uri == "/remote" || uri.isEmpty())) {
val htmlBytes = htmlPage.toByteArray(Charsets.UTF_8) val htmlBytes = htmlPage.toByteArray(Charsets.UTF_8)
return newCorsResponse( return newSameOriginResponse(
NanoHTTPD.Response.Status.OK, NanoHTTPD.Response.Status.OK,
"text/html; charset=utf-8", "text/html; charset=utf-8",
htmlBytes htmlBytes
) )
} }
if (session.method == NanoHTTPD.Method.POST && uri == "/cmd") { if (session.method == NanoHTTPD.Method.POST && (uri == "/cmd" || uri == "/upload")) {
return handleCommand(session) if (!isAuthorized(session)) {
Log.w(TAG, "Rejected unauthorized request to $uri from ${session.remoteIpAddress}")
return newSameOriginResponse(
NanoHTTPD.Response.Status.FORBIDDEN,
"application/json",
"{\"status\":\"error\",\"message\":\"Forbidden: invalid session token\"}".toByteArray(Charsets.UTF_8)
)
}
return if (uri == "/cmd") handleCommand(session) else handleUpload(session)
} }
if (session.method == NanoHTTPD.Method.POST && uri == "/upload") { return newSameOriginResponse(
return handleUpload(session)
}
return newCorsResponse(
NanoHTTPD.Response.Status.NOT_FOUND, NanoHTTPD.Response.Status.NOT_FOUND,
"text/plain", "text/plain",
"Not Found".toByteArray(Charsets.UTF_8) "Not Found".toByteArray(Charsets.UTF_8)
@@ -109,7 +120,7 @@ class RemoteControlServer(
if (body.isNullOrBlank()) { if (body.isNullOrBlank()) {
try { try {
val contentLength = session.headers["content-length"]?.toIntOrNull() ?: 0 val contentLength = session.headers["content-length"]?.toIntOrNull() ?: 0
if (contentLength > 0 && contentLength < 4096) { if (contentLength in 1..4095) {
val buf = ByteArray(contentLength) val buf = ByteArray(contentLength)
var offset = 0 var offset = 0
val stream = session.inputStream val stream = session.inputStream
@@ -152,7 +163,7 @@ class RemoteControlServer(
Log.e(TAG, "Parse cmd failed: $body", e) Log.e(TAG, "Parse cmd failed: $body", e)
} }
} }
return newCorsResponse( return newSameOriginResponse(
NanoHTTPD.Response.Status.OK, NanoHTTPD.Response.Status.OK,
"application/json", "application/json",
"{}".toByteArray(Charsets.UTF_8) "{}".toByteArray(Charsets.UTF_8)
@@ -172,18 +183,25 @@ class RemoteControlServer(
rawFilename = "upload_${System.currentTimeMillis()}" rawFilename = "upload_${System.currentTimeMillis()}"
} }
val cleanFilename = File(rawFilename).name.ifBlank { "uploaded_file" } val cleanFilename = File(rawFilename).name.ifBlank { "uploaded_file" }
if (!isAllowedUploadFilename(cleanFilename)) {
return newSameOriginResponse(
NanoHTTPD.Response.Status.BAD_REQUEST,
"application/json",
"{\"status\":\"error\",\"message\":\"Unsupported file type\"}".toByteArray(Charsets.UTF_8)
)
}
val tempFilePath = files["file"] ?: files.values.firstOrNull() val tempFilePath = files["file"] ?: files.values.firstOrNull()
if (tempFilePath != null) { if (tempFilePath != null) {
val tempFile = File(tempFilePath) val tempFile = File(tempFilePath)
if (tempFile.exists() && tempFile.length() > 0) { if (tempFile.exists() && tempFile.length() > 0) {
val uploadDir = File(context.getExternalFilesDir("uploads") ?: context.filesDir, "uploads") val uploadDir = getUploadDir(context)
uploadDir.mkdirs()
val targetFile = File(uploadDir, cleanFilename) val targetFile = File(uploadDir, cleanFilename)
tempFile.copyTo(targetFile, overwrite = true) tempFile.copyTo(targetFile, overwrite = true)
val clientIp = session.remoteIpAddress.orEmpty()
mainHandler.post { mainHandler.post {
callback?.onRemoteFileUploaded(targetFile, cleanFilename) callback?.onRemoteFileUploaded(targetFile, cleanFilename, clientIp)
} }
val respJson = JSONObject().apply { val respJson = JSONObject().apply {
@@ -191,7 +209,7 @@ class RemoteControlServer(
put("filename", cleanFilename) put("filename", cleanFilename)
put("size", targetFile.length()) put("size", targetFile.length())
} }
return newCorsResponse( return newSameOriginResponse(
NanoHTTPD.Response.Status.OK, NanoHTTPD.Response.Status.OK,
"application/json", "application/json",
respJson.toString().toByteArray(Charsets.UTF_8) respJson.toString().toByteArray(Charsets.UTF_8)
@@ -204,17 +222,41 @@ class RemoteControlServer(
put("status", "error") put("status", "error")
put("message", e.message ?: "unknown") put("message", e.message ?: "unknown")
} }
return newCorsResponse( return newSameOriginResponse(
NanoHTTPD.Response.Status.INTERNAL_ERROR, NanoHTTPD.Response.Status.INTERNAL_ERROR,
"application/json", "application/json",
errJson.toString().toByteArray(Charsets.UTF_8) errJson.toString().toByteArray(Charsets.UTF_8)
) )
} }
return newCorsResponse( return newSameOriginResponse(
NanoHTTPD.Response.Status.BAD_REQUEST, NanoHTTPD.Response.Status.BAD_REQUEST,
"application/json", "application/json",
"{\"status\":\"error\",\"message\":\"No file received\"}".toByteArray(Charsets.UTF_8) "{\"status\":\"error\",\"message\":\"No file received\"}".toByteArray(Charsets.UTF_8)
) )
} }
companion object {
private val ALLOWED_EXTENSIONS = setOf(
"apk", "mp4", "m3u8", "mkv", "flv", "ts", "webm", "mov", "avi",
"mp3", "flac", "wav", "aac", "ogg", "m4a",
"jpg", "jpeg", "png", "webp", "gif",
"srt", "ass", "vtt", "txt", "json", "js", "zip"
)
fun isAllowedUploadFilename(filename: String): Boolean {
val ext = filename.substringAfterLast('.', "").lowercase()
return ext in ALLOWED_EXTENSIONS
}
/**
* 统一获取局域网快传目录:
* getExternalFilesDir("uploads") 本身即指向 .../files/uploads,不再重复嵌套子目录。
*/
fun getUploadDir(context: Context): File {
val dir = context.getExternalFilesDir("uploads") ?: File(context.filesDir, "uploads")
if (!dir.exists()) dir.mkdirs()
return dir
}
}
} }
@@ -383,7 +383,8 @@ class SettingsDialog : DialogFragment() {
bindCardLeftKeyToTab(b.cardKeyGuide, 4) bindCardLeftKeyToTab(b.cardKeyGuide, 4)
bindCardLeftKeyToTab(b.cardRemoteServer, 4) bindCardLeftKeyToTab(b.cardRemoteServer, 4)
b.tvVersionDetail.text = "版本: v${BuildConfig.VERSION_NAME} (Release) · 引擎: Chromium WebView + ExoPlayer Media3" val buildTag = if (BuildConfig.DEBUG) "Debug" else "Release"
b.tvVersionDetail.text = "版本: v${BuildConfig.VERSION_NAME} ($buildTag) · 引擎: Chromium WebView + ExoPlayer Media3"
b.cardRemoteServer.setOnClickListener { b.cardRemoteServer.setOnClickListener {
act?.showRemoteControlDialog() act?.showRemoteControlDialog()
@@ -4,20 +4,20 @@ import android.app.Application
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import java.io.File import java.io.File
import java.io.FileWriter
import java.io.PrintWriter import java.io.PrintWriter
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
/** /**
* 应用入口。安装全局未捕获异常处理器,在崩溃时将日志写入文件。 * 应用入口。安装全局未捕获异常处理器,在崩溃时将日志写入文件,并定期裁剪保留最近 [MAX_CRASH_LOGS] 条。
*/ */
class TVBrowserApplication : Application() { class TVBrowserApplication : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
installCrashHandler() installCrashHandler()
pruneCrashLogsAsync()
} }
private fun installCrashHandler() { private fun installCrashHandler() {
@@ -32,6 +32,24 @@ class TVBrowserApplication : Application() {
} }
} }
private fun pruneCrashLogsAsync() {
val dir = File(filesDir, CRASH_LOG_DIR)
Thread {
try {
pruneCrashLogs(dir)
} catch (_: Throwable) { }
}.start()
}
private fun pruneCrashLogs(dir: File) {
if (!dir.exists()) return
val files = dir.listFiles { f -> f.isFile && f.name.startsWith("crash_") } ?: return
if (files.size <= MAX_CRASH_LOGS) return
files.sortedByDescending { it.lastModified() }
.drop(MAX_CRASH_LOGS)
.forEach { runCatching { it.delete() } }
}
private fun saveCrashLog(thread: Thread, throwable: Throwable) { private fun saveCrashLog(thread: Thread, throwable: Throwable) {
val dir = File(filesDir, CRASH_LOG_DIR) val dir = File(filesDir, CRASH_LOG_DIR)
if (!dir.exists()) dir.mkdirs() if (!dir.exists()) dir.mkdirs()
@@ -60,6 +78,7 @@ class TVBrowserApplication : Application() {
} }
pw.flush() pw.flush()
} }
runCatching { pruneCrashLogs(dir) }
Log.w(TAG, "崩溃日志已保存: ${file.absolutePath}") Log.w(TAG, "崩溃日志已保存: ${file.absolutePath}")
} }
@@ -67,5 +86,6 @@ class TVBrowserApplication : Application() {
private const val TAG = "TVBrowser.Crash" private const val TAG = "TVBrowser.Crash"
/** 崩溃日志所在子目录,可通过 Context.filesDir/CRASH_LOG_DIR 访问 */ /** 崩溃日志所在子目录,可通过 Context.filesDir/CRASH_LOG_DIR 访问 */
const val CRASH_LOG_DIR = "crash_logs" const val CRASH_LOG_DIR = "crash_logs"
private const val MAX_CRASH_LOGS = 20
} }
} }
@@ -1,38 +1,18 @@
package com.tv.browser package com.tv.browser
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.util.UUID import java.util.UUID
/** /**
* 用户脚本持久化:SharedPreferences + JSON。 * 用户脚本持久化:基于 [JsonListStore] 内存缓存 + SharedPreferences,
* 避免每次页面加载完成(onPageFinished)都在主线程重复解析 JSON。
*/ */
class UserScriptRepository(context: Context) { class UserScriptRepository(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) private val appContext = context.applicationContext
private val keyScripts = "user_scripts"
fun getAll(): List<UserScript> { fun getAll(): List<UserScript> = store.getAll(appContext)
val json = prefs.getString(keyScripts, null)
if (json.isNullOrBlank()) return emptyList()
return try {
val arr = JSONArray(json)
List(arr.length()) { i ->
val obj = arr.getJSONObject(i)
UserScript(
id = obj.optString(KEY_ID, UUID.randomUUID().toString()),
name = obj.optString(KEY_NAME, ""),
urlPattern = obj.optString(KEY_URL_PATTERN, "*"),
script = obj.optString(KEY_SCRIPT, ""),
enabled = obj.optBoolean(KEY_ENABLED, true)
)
}
} catch (_: Exception) {
emptyList()
}
}
fun getEnabledForUrl(url: String): List<UserScript> = fun getEnabledForUrl(url: String): List<UserScript> =
getAll().filter { it.enabled && it.script.isNotBlank() && it.matchesUrl(url) } getAll().filter { it.enabled && it.script.isNotBlank() && it.matchesUrl(url) }
@@ -42,7 +22,7 @@ class UserScriptRepository(context: Context) {
val newId = script.id.ifBlank { UUID.randomUUID().toString() } val newId = script.id.ifBlank { UUID.randomUUID().toString() }
list.removeAll { it.id == newId } list.removeAll { it.id == newId }
list.add(0, script.copy(id = newId)) list.add(0, script.copy(id = newId))
save(list) store.save(appContext, list)
} }
fun update(script: UserScript) { fun update(script: UserScript) {
@@ -50,7 +30,7 @@ class UserScriptRepository(context: Context) {
val idx = list.indexOfFirst { it.id == script.id } val idx = list.indexOfFirst { it.id == script.id }
if (idx >= 0) { if (idx >= 0) {
list[idx] = script list[idx] = script
save(list) store.save(appContext, list)
} else { } else {
add(script) add(script)
} }
@@ -61,34 +41,44 @@ class UserScriptRepository(context: Context) {
val idx = list.indexOfFirst { it.id == id } val idx = list.indexOfFirst { it.id == id }
if (idx >= 0) { if (idx >= 0) {
list[idx] = list[idx].copy(enabled = enabled) list[idx] = list[idx].copy(enabled = enabled)
save(list) store.save(appContext, list)
} }
} }
fun remove(id: String) { fun remove(id: String) {
save(getAll().filter { it.id != id }) store.save(appContext, getAll().filter { it.id != id })
}
private fun save(list: List<UserScript>) {
val arr = JSONArray()
list.forEach { s ->
arr.put(JSONObject().apply {
put(KEY_ID, s.id)
put(KEY_NAME, s.name)
put(KEY_URL_PATTERN, s.urlPattern)
put(KEY_SCRIPT, s.script)
put(KEY_ENABLED, s.enabled)
})
}
prefs.edit().putString(keyScripts, arr.toString()).apply()
} }
companion object { companion object {
private const val PREFS_NAME = "tv_browser_user_scripts" private const val PREFS_NAME = "tv_browser_user_scripts"
private const val KEY_SCRIPTS = "user_scripts"
private const val KEY_ID = "id" private const val KEY_ID = "id"
private const val KEY_NAME = "name" private const val KEY_NAME = "name"
private const val KEY_URL_PATTERN = "urlPattern" private const val KEY_URL_PATTERN = "urlPattern"
private const val KEY_SCRIPT = "script" private const val KEY_SCRIPT = "script"
private const val KEY_ENABLED = "enabled" private const val KEY_ENABLED = "enabled"
private val store = JsonListStore<UserScript>(
prefsName = PREFS_NAME,
key = KEY_SCRIPTS,
encode = { s ->
JSONObject().apply {
put(KEY_ID, s.id)
put(KEY_NAME, s.name)
put(KEY_URL_PATTERN, s.urlPattern)
put(KEY_SCRIPT, s.script)
put(KEY_ENABLED, s.enabled)
}
},
decode = { obj ->
UserScript(
id = obj.optString(KEY_ID, UUID.randomUUID().toString()),
name = obj.optString(KEY_NAME, ""),
urlPattern = obj.optString(KEY_URL_PATTERN, "*"),
script = obj.optString(KEY_SCRIPT, ""),
enabled = obj.optBoolean(KEY_ENABLED, true)
)
}
)
} }
} }
@@ -27,11 +27,6 @@ function __tvFindVideo(doc){
} }
""" """
/** 检测页面是否存在 video 元素(主文档 + iframe),返回 "yes" 或 "no";内部 console.log 便于 logcat 排查 */
val JS_HAS_VIDEO =
"(function(){ $FIND_VIDEO_FN var r=__tvFindVideo(document)?'yes':'no'; try{ console.log('TVBrowser findVideo:', r); }catch(e){} return r; })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 播放/暂停切换,返回 "playing" | "paused" | "no" */ /** 播放/暂停切换,返回 "playing" | "paused" | "no" */
val JS_TOGGLE_PLAY = val JS_TOGGLE_PLAY =
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; if(v.paused){ v.play(); return 'playing'; } else { v.pause(); return 'paused'; } })();" "(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; if(v.paused){ v.play(); return 'playing'; } else { v.pause(); return 'paused'; } })();"
@@ -67,35 +62,6 @@ function __tvFindVideo(doc){
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; try { var r=v.getBoundingClientRect(); var x=r.left+r.width/2, y=r.top+r.height/2; var opts={bubbles:true,cancelable:true,view:window,clientX:x,clientY:y}; v.dispatchEvent(new MouseEvent('dblclick',opts)); return 'ok'; } catch(err) { return 'no'; } })();" "(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; try { var r=v.getBoundingClientRect(); var x=r.left+r.width/2, y=r.top+r.height/2; var opts={bubbles:true,cancelable:true,view:window,clientX:x,clientY:y}; v.dispatchEvent(new MouseEvent('dblclick',opts)); return 'ok'; } catch(err) { return 'no'; } })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN) .replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 获取当前播放状态,返回 "playing" | "paused" | "no" */
val JS_GET_STATE =
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); return v ? (v.paused ? 'paused' : 'playing') : 'no'; })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 获取当前 video 的 src/currentSrc,返回 URL 或空;blob: 需在页内播,新页面不可用 */
val JS_GET_VIDEO_SRC =
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return ''; var s=(v.currentSrc||v.src||'').trim(); return s; })();"
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
/** 从页内 application/ld+json 中取 VideoObject.contentUrl(m3u8/mp4 等),返回 URL 或空 */
val JS_GET_CONTENT_URL = """
(function(){
var scripts=document.querySelectorAll('script[type="application/ld+json"]');
for(var i=0;i<scripts.length;i++){
try{
var o=JSON.parse(scripts[i].textContent);
var u=o&&(o.contentUrl||(o['@type']==='VideoObject'&&o.contentUrl));
if(u&&typeof u==='string'&&u.indexOf('http')===0) return u;
if(Array.isArray(o)) for(var j=0;j<o.length;j++){
var v=o[j];
if(v&&v['@type']==='VideoObject'&&v.contentUrl) return v.contentUrl;
}
}catch(e){}
}
return '';
})();
""".trimIndent()
/** /**
* 全屏增强注入脚本: * 全屏增强注入脚本:
* 1. 强制为所有 <iframe> 授予 allowfullscreen 属性及 Permissions Policy,解决聚合站 iframe 视频全屏被拦截的问题 * 1. 强制为所有 <iframe> 授予 allowfullscreen 属性及 Permissions Policy,解决聚合站 iframe 视频全屏被拦截的问题
@@ -23,6 +23,18 @@ object VideoSnifferHelper {
private val VIDEO_EXTENSIONS = arrayOf(".m3u8", ".mp4", ".flv", ".f4v", ".webm") private val VIDEO_EXTENSIONS = arrayOf(".m3u8", ".mp4", ".flv", ".f4v", ".webm")
private fun extractPath(url: String): String? =
runCatching { java.net.URI(url.trim()).path }.getOrNull()
?: runCatching { Uri.parse(url).path }.getOrNull()
?: url.trim().substringAfter("://", "").substringAfter('/', "").let {
if (it.isEmpty()) null else "/" + it.substringBefore('?').substringBefore('#')
}
private fun extractHost(url: String): String? =
runCatching { java.net.URI(url.trim()).host }.getOrNull()
?: runCatching { Uri.parse(url).host }.getOrNull()
?: url.trim().substringAfter("://", "").substringBefore('/').substringBefore(':').takeIf { it.isNotEmpty() }
/** /**
* 判断一个请求 URL 是否为多媒体视频流地址 * 判断一个请求 URL 是否为多媒体视频流地址
*/ */
@@ -39,11 +51,7 @@ object VideoSnifferHelper {
} }
// 解析 Path // 解析 Path
val path = try { val path = extractPath(url)?.lowercase() ?: lower
Uri.parse(url).path?.lowercase() ?: ""
} catch (_: Exception) {
lower
}
for (ext in VIDEO_EXTENSIONS) { for (ext in VIDEO_EXTENSIONS) {
if (path.endsWith(ext) || lower.contains("$ext?") || lower.contains("$ext#") || lower.endsWith(ext)) { if (path.endsWith(ext) || lower.contains("$ext?") || lower.contains("$ext#") || lower.endsWith(ext)) {
@@ -66,11 +74,7 @@ object VideoSnifferHelper {
* 后台解析 WebView 已开启 blockNetworkImage,能走到这里的图片后缀请求基本是 XHR 加载的伪装分片。 * 后台解析 WebView 已开启 blockNetworkImage,能走到这里的图片后缀请求基本是 XHR 加载的伪装分片。
*/ */
fun isMediaSegmentUrl(url: String): Boolean { fun isMediaSegmentUrl(url: String): Boolean {
val path = try { val path = extractPath(url)?.lowercase() ?: return false
Uri.parse(url).path?.lowercase() ?: return false
} catch (_: Exception) {
return false
}
return SEGMENT_EXTENSIONS.any { path.endsWith(it) } || DISGUISED_SEGMENT_EXTENSIONS.any { path.endsWith(it) } return SEGMENT_EXTENSIONS.any { path.endsWith(it) } || DISGUISED_SEGMENT_EXTENSIONS.any { path.endsWith(it) }
} }
@@ -111,10 +115,9 @@ object VideoSnifferHelper {
* 目前:央视网 CNTV 的 h5e / enc / enc2 清单(如 dh5ws01.v.cntv.cn/asp/h5e/hls/...)。 * 目前:央视网 CNTV 的 h5e / enc / enc2 清单(如 dh5ws01.v.cntv.cn/asp/h5e/hls/...)。
*/ */
fun isWebOnlyStream(url: String): Boolean { fun isWebOnlyStream(url: String): Boolean {
val uri = try { Uri.parse(url.trim()) } catch (_: Exception) { return false } val host = extractHost(url)?.lowercase() ?: return false
val host = uri.host?.lowercase() ?: return false
if (!host.contains("cntv") && !host.contains("cctv")) return false if (!host.contains("cntv") && !host.contains("cctv")) return false
return WEB_ONLY_PATH.containsMatchIn(uri.path ?: "") return WEB_ONLY_PATH.containsMatchIn(extractPath(url) ?: "")
} }
/** /**
@@ -124,16 +127,12 @@ object VideoSnifferHelper {
fun isLikelyVariantOf(newUrl: String, existingUrl: String): Boolean { fun isLikelyVariantOf(newUrl: String, existingUrl: String): Boolean {
if (newUrl == existingUrl) return true if (newUrl == existingUrl) return true
if (!isHls(newUrl) || !isHls(existingUrl)) return false if (!isHls(newUrl) || !isHls(existingUrl)) return false
return try { val hostA = extractHost(newUrl) ?: return false
val a = Uri.parse(newUrl) val hostB = extractHost(existingUrl) ?: return false
val b = Uri.parse(existingUrl) if (hostA != hostB) return false
if (a.host != b.host) return false val dirA = extractPath(newUrl)?.substringBeforeLast('/') ?: return false
val dirA = a.path?.substringBeforeLast('/') ?: return false val dirB = extractPath(existingUrl)?.substringBeforeLast('/') ?: return false
val dirB = b.path?.substringBeforeLast('/') ?: return false return dirA.startsWith(dirB)
dirA.startsWith(dirB)
} catch (_: Exception) {
false
}
} }
// --------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------
@@ -1041,12 +1040,21 @@ object VideoSnifferHelper {
} }
} }
window.__tvFindVideosNow = function() { findVideos(document); };
findVideos(document); findVideos(document);
// 监听后续动态添加或加载的视频 if (window.__tvDomSnifferInstalled) return;
window.__tvDomSnifferInstalled = true;
// 节流监听后续动态添加或加载的视频,避免高频 DOM 变动卡顿
var sniffTimer = 0;
try { try {
new MutationObserver(function() { new MutationObserver(function() {
findVideos(document); if (sniffTimer) return;
sniffTimer = setTimeout(function() {
sniffTimer = 0;
findVideos(document);
}, 400);
}).observe(document.documentElement || document.body, { childList: true, subtree: true }); }).observe(document.documentElement || document.body, { childList: true, subtree: true });
} catch(e) {} } catch(e) {}
} catch(e) {} } catch(e) {}
@@ -38,23 +38,6 @@ data class Episode(val name: String, val url: String)
/** 播放线路(如「NB源」「量子资源」),每条线路有自己的选集列表 */ /** 播放线路(如「NB源」「量子资源」),每条线路有自己的选集列表 */
data class PlaySource(val name: String, val episodes: List<Episode>) { data class PlaySource(val name: String, val episodes: List<Episode>) {
companion object { companion object {
/** 线路列表与 JSON 互转(用于 Intent 传递) */
fun listToJson(list: List<PlaySource>): String {
val arr = JSONArray()
list.forEach { s ->
arr.put(JSONObject().apply {
put("name", s.name)
put("episodes", episodesToJson(s.episodes))
})
}
return arr.toString()
}
fun listFromJson(json: String?): List<PlaySource> {
if (json.isNullOrBlank()) return emptyList()
return try { parseArray(JSONArray(json)) } catch (_: Exception) { emptyList() }
}
internal fun parseArray(arr: JSONArray?): List<PlaySource> { internal fun parseArray(arr: JSONArray?): List<PlaySource> {
if (arr == null) return emptyList() if (arr == null) return emptyList()
val out = mutableListOf<PlaySource>() val out = mutableListOf<PlaySource>()
@@ -76,12 +59,6 @@ data class PlaySource(val name: String, val episodes: List<Episode>) {
} }
return eps return eps
} }
private fun episodesToJson(list: List<Episode>): JSONArray {
val arr = JSONArray()
list.forEach { arr.put(JSONObject().put("name", it.name).put("url", it.url)) }
return arr
}
} }
} }
@@ -1,112 +0,0 @@
package com.tv.browser
import android.annotation.SuppressLint
import android.os.Bundle
import android.view.ViewGroup
import android.webkit.WebView
import androidx.appcompat.app.AppCompatActivity
import com.tv.browser.databinding.ActivityVideoWebBinding
/**
* 新页面播放:用 WebView 加载仅含 &lt;video src="url"&gt; 的简单页,用于直链或 contentUrl。
*/
class VideoWebActivity : AppCompatActivity() {
private lateinit var binding: ActivityVideoWebBinding
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityVideoWebBinding.inflate(layoutInflater)
setContentView(binding.root)
val url = intent.getStringExtra(EXTRA_VIDEO_URL) ?: ""
if (url.isBlank()) {
finish()
return
}
binding.webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
useWideViewPort = true
loadWithOverviewMode = true
}
binding.webView.webViewClient = object : android.webkit.WebViewClient() {
override fun onReceivedSslError(
view: WebView?,
handler: android.webkit.SslErrorHandler?,
error: android.net.http.SslError?
) {
handler?.proceed()
}
}
val html = """
<!DOCTYPE html>
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;background:#000;">
<video src="${escapeHtmlAttr(url)}" controls autoplay playsinline style="width:100%;height:100%;object-fit:contain;"></video>
</body></html>
""".trimIndent()
binding.webView.loadDataWithBaseURL(
intent.getStringExtra(EXTRA_BASE_URL).orEmpty(),
html,
"text/html",
"UTF-8",
null
)
}
override fun onResume() {
super.onResume()
if (::binding.isInitialized) {
binding.webView.onResume()
}
}
override fun onPause() {
if (::binding.isInitialized) {
try {
binding.webView.evaluateJavascript(VideoSnifferHelper.JS_PAUSE_ALL_MEDIA, null)
binding.webView.onPause()
} catch (_: Exception) { }
}
super.onPause()
}
override fun onStop() {
if (::binding.isInitialized) {
try {
binding.webView.evaluateJavascript(VideoSnifferHelper.JS_PAUSE_ALL_MEDIA, null)
binding.webView.onPause()
} catch (_: Exception) { }
}
super.onStop()
}
override fun onDestroy() {
if (::binding.isInitialized) {
try {
binding.webView.stopLoading()
(binding.webView.parent as? ViewGroup)?.removeView(binding.webView)
binding.webView.destroy()
} catch (_: Exception) { }
}
super.onDestroy()
}
private fun escapeHtmlAttr(s: String): String {
return s.replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
}
companion object {
const val EXTRA_VIDEO_URL = "video_url"
const val EXTRA_BASE_URL = "base_url"
}
}
@@ -10,15 +10,8 @@ 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(
@@ -242,12 +235,10 @@ class VodSearchHelper(context: Context) {
} }
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 useLenient = AppSettings.isLenientSsl(appContext) || NetworkChecker.isSystemClockAbnormal()
val conn = URL(url).openConnection() as HttpURLConnection val conn = URL(url).openConnection() as HttpURLConnection
try { try {
if (conn is HttpsURLConnection) { PlayerSsl.applyTo(conn, lenient = useLenient)
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
@@ -256,7 +247,9 @@ class VodSearchHelper(context: Context) {
if (ajax) conn.setRequestProperty("X-Requested-With", "XMLHttpRequest") if (ajax) conn.setRequestProperty("X-Requested-With", "XMLHttpRequest")
conn.setRequestProperty("Accept", if (ajax) "application/json, text/javascript, */*" else "text/html,*/*") conn.setRequestProperty("Accept", if (ajax) "application/json, text/javascript, */*" else "text/html,*/*")
if (!cookie.isNullOrBlank()) conn.setRequestProperty("Cookie", cookie) if (!cookie.isNullOrBlank()) conn.setRequestProperty("Cookie", cookie)
if (conn.responseCode !in 200..299) return null val code = conn.responseCode
NetworkChecker.updateServerDate(conn.date)
if (code !in 200..299) return null
val bytes = conn.inputStream.use { input -> val bytes = conn.inputStream.use { input ->
val buf = java.io.ByteArrayOutputStream() val buf = java.io.ByteArrayOutputStream()
val chunk = ByteArray(16 * 1024) val chunk = ByteArray(16 * 1024)
@@ -302,22 +295,11 @@ class VodSearchHelper(context: Context) {
lastSearchResults = results lastSearchResults = results
} }
private val lenientHostnameVerifier = HostnameVerifier { _, _ -> true }
private val sharedExecutor: ExecutorService by lazy { private val sharedExecutor: ExecutorService by lazy {
Executors.newFixedThreadPool(THREADS) { r -> Executors.newFixedThreadPool(THREADS) { r ->
Thread(r, "VodSearchWorker").apply { isDaemon = true } 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(
+2 -2
View File
@@ -2,13 +2,13 @@
<selector xmlns:android="http://schemas.android.com/apk/res/android"> <selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true"> <item android:state_focused="true">
<shape android:shape="rectangle"> <shape android:shape="rectangle">
<solid android:color="@color/focus_highlight" /> <solid android:color="@color/accent_sky" />
<corners android:radius="4dp" /> <corners android:radius="4dp" />
</shape> </shape>
</item> </item>
<item> <item>
<shape android:shape="rectangle"> <shape android:shape="rectangle">
<solid android:color="@color/url_bar_bg" /> <solid android:color="@color/card_bg" />
<corners android:radius="4dp" /> <corners android:radius="4dp" />
</shape> </shape>
</item> </item>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/toolbar_bg"
android:padding="24dp"
android:minWidth="400dp"
android:minHeight="300dp">
<TextView
android:id="@+id/dialogTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/bookmarks"
android:textColor="@color/white"
android:textSize="20sp"
android:paddingBottom="16dp" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:minHeight="120dp">
<ListView
android:id="@+id/bookmarkList"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:divider="#33FFFFFF"
android:dividerHeight="1dp"
android:focusable="true"
android:focusableInTouchMode="true"
android:descendantFocusability="afterDescendants" />
<TextView
android:id="@+id/emptyHint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/no_bookmarks"
android:textColor="#99FFFFFF"
android:textSize="14sp"
android:padding="24dp"
android:visibility="gone" />
</FrameLayout>
<Button
android:id="@+id/btnClose"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="48dp"
android:layout_gravity="end"
android:layout_marginTop="16dp"
android:text="@string/close"
android:minWidth="80dp" />
</LinearLayout>
@@ -1,53 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/toolbar_bg"
android:padding="24dp"
android:minWidth="320dp">
<TextView
android:id="@+id/dialogVideoPlayModeTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/video_play_mode_title"
android:textColor="@color/white"
android:textSize="18sp"
android:paddingBottom="16dp" />
<Button
android:id="@+id/btnPlayModeCurrent"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/video_play_mode_current"
android:minWidth="0dp" />
<Button
android:id="@+id/btnPlayModeNewPage"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:text="@string/video_play_mode_new_page"
android:minWidth="0dp" />
<Button
android:id="@+id/btnPlayModeNative"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="8dp"
android:text="@string/video_play_mode_native"
android:minWidth="0dp" />
<Button
android:id="@+id/btnPlayModeCancel"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="16dp"
android:text="@string/video_play_mode_cancel"
android:minWidth="0dp" />
</LinearLayout>
-55
View File
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/bookmarkItemRoot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:background="@drawable/btn_focus_bg"
android:focusable="true"
android:focusableInTouchMode="true"
android:clickable="true"
android:minHeight="56dp">
<LinearLayout
android:id="@+id/bookmarkContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:focusable="false"
android:clickable="true">
<TextView
android:id="@+id/bookmarkTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="16sp"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/bookmarkUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#99FFFFFF"
android:textSize="12sp"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>
<Button
android:id="@+id/btnRemove"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginStart="12dp"
android:text="@string/remove"
android:minWidth="64dp" />
</LinearLayout>
@@ -1,82 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/videoControlBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:background="#E6000000"
android:visibility="gone"
android:focusable="false">
<Button
android:id="@+id/videoBtnPlayPause"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:text="@string/video_play"
android:minWidth="72dp" />
<Button
android:id="@+id/videoBtnRewind"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:text="@string/video_rewind"
android:minWidth="72dp" />
<Button
android:id="@+id/videoBtnForward"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:text="@string/video_forward"
android:minWidth="72dp" />
<Button
android:id="@+id/videoBtnVolumeDown"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:text="@string/video_volume_down"
android:minWidth="56dp" />
<Button
android:id="@+id/videoBtnVolumeUp"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:text="@string/video_volume_up"
android:minWidth="56dp" />
<Button
android:id="@+id/videoBtnFullscreen"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:text="@string/video_fullscreen"
android:minWidth="72dp" />
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<Button
android:id="@+id/videoBtnClose"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:text="@string/video_close"
android:minWidth="72dp" />
</LinearLayout>
+11 -6
View File
@@ -1,13 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- TV 桌面图标/横幅背景色,修改此值即可更换桌面上的图标背景(勿用纯白 #FFFFFFFF 易与桌面融为一体) --> <!-- TV 桌面图标/横幅背景色,修改此值即可更换桌面上的图标背景 -->
<color name="tv_banner_background">#FFFFFFFF</color> <color name="tv_banner_background">#FFFFFFFF</color>
<color name="black">#FF000000</color> <color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color> <color name="white">#FFFFFFFF</color>
<color name="primary">#FF6200EE</color> <color name="primary">#FF38BDF8</color>
<color name="primary_dark">#FF3700B3</color> <color name="primary_dark">#FF0284C7</color>
<color name="accent">#FF03DAC5</color> <color name="accent">#FF38BDF8</color>
<color name="toolbar_bg">#FF1E1E1E</color> <color name="toolbar_bg">#FF1E1E1E</color>
<color name="url_bar_bg">#FF2D2D2D</color> <!-- 统一高频 UI 色彩(背景、卡片、主/次文字、强调色) -->
<color name="focus_highlight">#FF03DAC5</color> <color name="surface_bg">#FF141824</color>
<color name="card_bg">#FF1E293B</color>
<color name="text_primary">#FFFFFFFF</color>
<color name="text_secondary">#FF94A3B8</color>
<color name="text_muted">#FF64748B</color>
<color name="accent_sky">#FF38BDF8</color>
</resources> </resources>
-24
View File
@@ -4,21 +4,15 @@
<string name="cursor_pointer_desc">模拟鼠标指针</string> <string name="cursor_pointer_desc">模拟鼠标指针</string>
<string name="url_hint">输入网址或搜索</string> <string name="url_hint">输入网址或搜索</string>
<string name="go">前往</string> <string name="go">前往</string>
<string name="back">后退</string>
<string name="forward">前进</string>
<string name="mouse_mode">鼠标模式</string>
<string name="card_edit">卡片整理</string> <string name="card_edit">卡片整理</string>
<string name="card_edit_done">完成整理</string> <string name="card_edit_done">完成整理</string>
<string name="refresh">刷新</string> <string name="refresh">刷新</string>
<string name="home">首页</string> <string name="home">首页</string>
<string name="default_home_url">https://www.ppnix.com/cn/</string> <string name="default_home_url">https://www.ppnix.com/cn/</string>
<string name="loading">加载中…</string>
<string name="error_page">无法加载此页</string> <string name="error_page">无法加载此页</string>
<string name="bookmark">收藏</string> <string name="bookmark">收藏</string>
<string name="bookmarks">收藏夹</string>
<string name="bookmark_added">已加入收藏</string> <string name="bookmark_added">已加入收藏</string>
<string name="bookmark_removed">已移除收藏</string> <string name="bookmark_removed">已移除收藏</string>
<string name="no_bookmarks">暂无收藏,浏览时点击「收藏」可添加</string>
<string name="remove">移除</string> <string name="remove">移除</string>
<string name="close">关闭</string> <string name="close">关闭</string>
<string name="remote_control_title">触屏遥控</string> <string name="remote_control_title">触屏遥控</string>
@@ -42,26 +36,8 @@
<string name="network_checking">正在检测网络…</string> <string name="network_checking">正在检测网络…</string>
<string name="internet_ok">互联网:正常</string> <string name="internet_ok">互联网:正常</string>
<string name="internet_unreachable">互联网:不可达,请检查 DNS、路由器或代理</string> <string name="internet_unreachable">互联网:不可达,请检查 DNS、路由器或代理</string>
<string name="video_controls">视频</string>
<string name="video_play">播放</string>
<string name="video_pause">暂停</string>
<string name="video_rewind">后退10秒</string>
<string name="video_forward">前进10秒</string>
<string name="video_volume_down">音量-</string>
<string name="video_volume_up">音量+</string>
<string name="video_fullscreen">全屏</string>
<string name="video_close">关闭</string>
<string name="video_not_found">当前页面未检测到视频</string>
<string name="video_fullscreen_unsupported">当前设备不支持视频全屏</string>
<string name="video_fullscreen_app_mode">已进入全屏模式,按返回键退出</string> <string name="video_fullscreen_app_mode">已进入全屏模式,按返回键退出</string>
<string name="video_fullscreen_exit">已退出全屏</string>
<string name="video_play_mode_title">选择播放方式</string>
<string name="video_play_mode_current">当前页控制</string>
<string name="video_play_mode_new_page">新页面播放</string>
<string name="video_play_mode_native">原生播放</string>
<string name="video_play_mode_cancel">取消</string> <string name="video_play_mode_cancel">取消</string>
<string name="video_url_not_available">无法获取播放地址</string>
<string name="video_blob_not_supported">该视频为流式地址,无法在新页面播放</string>
<string name="user_scripts">用户脚本</string> <string name="user_scripts">用户脚本</string>
<string name="user_scripts_hint">油猴式:在匹配的页面加载后注入自定义 JS</string> <string name="user_scripts_hint">油猴式:在匹配的页面加载后注入自定义 JS</string>
<string name="user_script_add">添加脚本</string> <string name="user_script_add">添加脚本</string>
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android"> <paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-files-path name="uploads" path="uploads" /> <external-files-path name="uploads" path="uploads" />
<files-path name="internal_uploads" path="uploads" />
<cache-path name="cache" path="." /> <cache-path name="cache" path="." />
</paths> </paths>
@@ -0,0 +1,194 @@
package com.tv.browser
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class CoreLogicTest {
@Before
fun setUp() {
PlaylistCache.clear()
}
@Test
fun testIsVideoStreamUrl() {
assertTrue(VideoSnifferHelper.isVideoStreamUrl("https://cdn.example.com/hls/index.m3u8"))
assertTrue(VideoSnifferHelper.isVideoStreamUrl("https://cdn.example.com/hls/index.m3u8?token=abc&t=123"))
assertTrue(VideoSnifferHelper.isVideoStreamUrl("http://v.example.com/movie/ep01.mp4"))
assertTrue(VideoSnifferHelper.isVideoStreamUrl("https://v.example.com/live/stream.flv#live"))
assertFalse(VideoSnifferHelper.isVideoStreamUrl("https://cdn.example.com/poster/mp4_cover.jpg"))
assertFalse(VideoSnifferHelper.isVideoStreamUrl("https://cdn.example.com/hls/seg0001.ts"))
assertFalse(VideoSnifferHelper.isVideoStreamUrl("https://cdn.example.com/hls/enc.key"))
assertFalse(VideoSnifferHelper.isVideoStreamUrl("blob:https://example.com/1234-5678"))
}
@Test
fun testIsHlsAndMediaSegment() {
assertTrue(VideoSnifferHelper.isHls("https://cdn.example.com/play.m3u8"))
assertTrue(VideoSnifferHelper.isHls("https://cdn.example.com/play", kind = "hls"))
assertFalse(VideoSnifferHelper.isHls("https://cdn.example.com/play.mp4"))
assertTrue(VideoSnifferHelper.isMediaSegmentUrl("https://cdn.example.com/hls/0001.ts?sign=1"))
assertTrue(VideoSnifferHelper.isMediaSegmentUrl("https://cdn.example.com/hls/disguised_seg.jpg"))
assertFalse(VideoSnifferHelper.isMediaSegmentUrl("https://cdn.example.com/hls/index.m3u8"))
}
@Test
fun testIsLikelyVariantOf() {
val master = "https://cdn.example.com/20260401/abc/index.m3u8"
val sub = "https://cdn.example.com/20260401/abc/1080p/hls/index.m3u8"
val otherEpisode = "https://cdn.example.com/20260401/def/index.m3u8"
val otherHost = "https://cdn2.example.com/20260401/abc/1080p/index.m3u8"
assertTrue(VideoSnifferHelper.isLikelyVariantOf(sub, master))
assertTrue(VideoSnifferHelper.isLikelyVariantOf(master, master))
assertFalse(VideoSnifferHelper.isLikelyVariantOf(otherEpisode, master))
assertFalse(VideoSnifferHelper.isLikelyVariantOf(otherHost, master))
}
@Test
fun testIsWebOnlyStream() {
assertTrue(VideoSnifferHelper.isWebOnlyStream("https://dh5ws01.v.cntv.cn/asp/h5e/hls/main/0303000a/3/default/index.m3u8"))
assertTrue(VideoSnifferHelper.isWebOnlyStream("https://hls.cntv.myhwcdn.cn/asp/enc/hls/main/index.m3u8"))
assertFalse(VideoSnifferHelper.isWebOnlyStream("https://hls.cntv.myhwcdn.cn/asp/hls/main/0303000a/3/default/index.m3u8"))
assertFalse(VideoSnifferHelper.isWebOnlyStream("https://cdn.example.com/asp/h5e/index.m3u8"))
}
@Test
fun testBuildPlaybackHeadersAndCache() {
val cache = VideoSnifferHelper.RequestHeaderCache(maxSize = 2)
cache.put("u1", mapOf("Origin" to "https://a.com"))
cache.put("u2", mapOf("Origin" to "https://b.com"))
cache.put("u3", mapOf("Origin" to "https://c.com"))
assertNull(cache.get("u1"))
assertEquals("https://c.com", cache.get("u3")?.get("Origin"))
val captured = mapOf(
"Origin" to "https://movie.example.com",
"Referer" to "https://movie.example.com/play/1",
"Range" to "bytes=0-100",
"Sec-Fetch-Site" to "cross-site"
)
val built = VideoSnifferHelper.buildPlaybackHeaders(
url = "https://cdn.example.com/v.m3u8",
captured = captured,
userAgent = "TestUA/1.0",
fallbackReferer = "https://fallback.example.com"
)
assertEquals("https://movie.example.com", built["Origin"])
assertEquals("https://movie.example.com/play/1", built["Referer"])
assertEquals("TestUA/1.0", built["User-Agent"])
assertFalse(built.containsKey("Range"))
assertFalse(built.containsKey("Sec-Fetch-Site"))
val fallbackBuilt = VideoSnifferHelper.buildPlaybackHeaders(
url = "https://cdn.example.com/v.m3u8",
captured = null,
userAgent = "TestUA/1.0",
fallbackReferer = "https://fallback.example.com/"
)
assertEquals("https://fallback.example.com/", fallbackBuilt["Referer"])
}
@Test
fun testRegistrableDomainAndSeriesKey() {
assertEquals("example.com", ContentFilterHelper.registrableDomain("www.sub.example.com"))
assertEquals("example.com.cn", ContentFilterHelper.registrableDomain("v.example.com.cn"))
assertEquals("192.168.1.1", ContentFilterHelper.registrableDomain("192.168.1.1"))
assertNull(ContentFilterHelper.registrableDomain(""))
assertEquals(
"example.com|庆余年第二季",
AppSettings.seriesKey("https://www.example.com/vodplay/123-1-1.html", " 庆余年第二季 ")
)
assertEquals("", AppSettings.seriesKey("https://www.example.com/play", " "))
}
@Test
fun testAdBlockHelper() {
assertTrue(AdBlockHelper.shouldBlockUrl("https://cpro.baidustatic.com/cpro/ui/c.js"))
assertTrue(AdBlockHelper.shouldBlockUrl("https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"))
assertFalse(AdBlockHelper.shouldBlockUrl("https://v.qq.com/x/cover/mzc00200.html"))
assertTrue(AdBlockHelper.isHijackScheme("tbopen://m.taobao.com/tbopen/index.html"))
assertTrue(AdBlockHelper.isHijackScheme("alipays://platformapi/startapp"))
assertTrue(AdBlockHelper.isHijackScheme("intent://scan/#Intent;scheme=zxing;end"))
assertFalse(AdBlockHelper.isHijackScheme("https://www.bilibili.com"))
assertFalse(AdBlockHelper.isHijackScheme("javascript:void(0)"))
}
@Test
fun testPlaylistCacheMasterAndSub() {
val masterUrl = "https://cdn.example.com/hls/master.m3u8"
val subUrl = "https://cdn.example.com/hls/1080p/playlist.m3u8"
val masterText = """
#EXTM3U
#EXT-X-STREAM-INF:BANDWIDTH=3000000,RESOLUTION=1920x1080
1080p/playlist.m3u8
""".trimIndent()
val vodSubText = """
#EXTM3U
#EXT-X-TARGETDURATION:10
#EXTINF:10.0,
seg0.ts
#EXT-X-ENDLIST
""".trimIndent()
PlaylistCache.put(masterUrl, masterUrl, masterText)
assertFalse(PlaylistCache.isReady(masterUrl))
PlaylistCache.put(subUrl, subUrl, vodSubText)
assertTrue(PlaylistCache.isReady(masterUrl))
assertTrue(PlaylistCache.isReady(subUrl))
// VOD playlist should remain cached after take()
assertEquals(vodSubText, PlaylistCache.take(subUrl)?.text)
assertTrue(PlaylistCache.contains(subUrl))
// Live playlist (without #EXT-X-ENDLIST) should be consumed once on take()
val liveUrl = "https://cdn.example.com/live/stream.m3u8"
val liveText = "#EXTM3U\n#EXTINF:4.0,\nseg1.ts"
PlaylistCache.put(liveUrl, null, liveText)
assertEquals(liveText, PlaylistCache.take(liveUrl)?.text)
assertFalse(PlaylistCache.contains(liveUrl))
}
@Test
fun testPlaybackRecordAndUserScript() {
val record = PlaybackRecord(
url = "https://cdn.example.com/1.m3u8",
title = "漫长的季节 - 详情",
positionMs = 45_000,
durationMs = 90_000,
seriesTitle = "漫长的季节",
episodeName = "第03集"
)
assertEquals(50, record.getProgressPercent())
assertEquals("漫长的季节", record.displayTitle())
assertEquals("第03集 · 50%", record.displaySubtitle())
assertEquals("00:45", record.formatPosition())
val script = UserScript(
id = "s1",
name = "Test Script",
urlPattern = "example.com/vod",
script = "console.log(1);"
)
assertTrue(script.matchesUrl("https://www.example.com/vod/1.html"))
assertFalse(script.matchesUrl("https://other.org/vod/1.html"))
}
@Test
fun testRemoteUploadWhitelist() {
assertTrue(RemoteControlServer.isAllowedUploadFilename("app_release.apk"))
assertTrue(RemoteControlServer.isAllowedUploadFilename("movie.MP4"))
assertTrue(RemoteControlServer.isAllowedUploadFilename("playlist.m3u8"))
assertFalse(RemoteControlServer.isAllowedUploadFilename("exploit.sh"))
assertFalse(RemoteControlServer.isAllowedUploadFilename("page.html"))
assertFalse(RemoteControlServer.isAllowedUploadFilename(""))
}
}
BIN
View File
Binary file not shown.
+2
View File
@@ -1,5 +1,7 @@
# Project-wide Gradle settings. # Project-wide Gradle settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
android.useAndroidX=true android.useAndroidX=true
kotlin.code.style=official kotlin.code.style=official
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
-11
View File
@@ -3,9 +3,6 @@ agp = "8.2.2"
kotlin = "1.9.22" kotlin = "1.9.22"
coreKtx = "1.12.0" coreKtx = "1.12.0"
appcompat = "1.6.1" appcompat = "1.6.1"
material = "1.11.0"
constraintlayout = "2.1.4"
leanback = "1.2.0-alpha04"
fragment = "1.6.2" fragment = "1.6.2"
recyclerview = "1.3.2" recyclerview = "1.3.2"
nanohttpd = "2.3.1" nanohttpd = "2.3.1"
@@ -13,27 +10,19 @@ zxing = "3.5.2"
media3 = "1.3.1" media3 = "1.3.1"
webkit = "1.10.0" webkit = "1.10.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
leanback = { group = "androidx.leanback", name = "leanback", version.ref = "leanback" }
fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" } fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" }
recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
nanohttpd = { group = "org.nanohttpd", name = "nanohttpd", version.ref = "nanohttpd" } nanohttpd = { group = "org.nanohttpd", name = "nanohttpd", version.ref = "nanohttpd" }
nanohttpd-websocket = { group = "org.nanohttpd", name = "nanohttpd-websocket", version.ref = "nanohttpd" }
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" } zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" } media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" } androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
junitVersion = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
espressoCore = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
Vendored
+252
View File
@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+142
View File
@@ -0,0 +1,142 @@
---
# 一份「先搬砖再装修」的优化清单
先说结论:这个项目的**业务复杂度堆得比工程基建快很多**。嗅探→解析→原生播放这条链路写得很实在(`PlaylistCache` 解决一次性 m3u8、`seriesPayloads` 规避 `TransactionTooLargeException`、`PlayerSsl` 兜底老电视证书,都是有真实现场经验的方案,注释也讲清了 why)。真正欠账的是**安全边界、功能细节 Bug、构建可复现性、可测试性、架构解耦与颜色资源化**。
---
## P0 · 安全与功能级 Bug(建议优先做,改动小、风险降幅最大)
**1. 手机遥控 HTTP 服务完全没有鉴权,且常驻监听 `0.0.0.0:8766`**
- `RemoteControlServer.kt:69-97` 的 `serve()` 不校验任何 token/cookie/来源;`RemoteControlServer.kt:56-67` 还返回 `Access-Control-Allow-Origin: *`。同一个 WiFi 下任何设备(甚至同一浏览器里打开的任意网页)都能 `POST /cmd`。
- 通过 `/cmd` 能做的:`open_url` 让电视加载任意地址(`BrowserActivity.kt:899-907`)、`text` 往当前输入框注入文字、`key` 模拟按键。
- 更严重的是 `/upload`(`RemoteControlServer.kt:162-219`)→ 落盘 → `.apk` 触发安装器(`RemoteControlHelper.kt:171-199`,配合 Manifest 的 `REQUEST_INSTALL_PACKAGES`)。即"局域网内任意人可投递 APK 到电视,只差用户点一下确认"。
- 建议方向:去掉 `Access-Control-Allow-Origin: *`(阻断恶意网页通过 JS 跨站请求 `127.0.0.1:8766` 的本地 CSRF),并在服务端渲染 `remote.html` 时内嵌随机 Session Token 供同源请求校验;上传目录加扩展名白名单,`.apk` 弹窗明确展示来源并走二次确认。
**2. `PlayerSsl.enableLenient()` 是进程级、永久、不可回滚的全局降级**
- `PlayerSsl.kt:60-79` 改了 `HttpsURLConnection.setDefaultSSLSocketFactory` + `setDefaultHostnameVerifier`,一旦播放中遇到一次 SSL 错误,**整个进程**后续所有 `HttpsURLConnection`(`VodSearchHelper` 抓站、其他网络请求)都不再校验证书。
- 建议:不要动全局默认值。给 ExoPlayer 单独配 `OkHttpDataSource.Factory` 或自定义 `DataSource` 的 SSLSocketFactory,作用域收敛到播放器;`VodSearchHelper` 用独立的 client。
**3. Manifest 备份与安装权限偏松**
- `allowBackup="true"`:收藏、浏览历史、播放记录会被云备份/`adb backup` 导出。
- `REQUEST_INSTALL_PACKAGES` 与上面的上传链路组合即攻击面,见第 1 条。
**4. 写死年份的"系统时钟异常"判定会漂移**
- `BrowserActivity.kt:334-338` 用 `Calendar.YEAR < 2025` 放行证书错误,`BrowserUiHelper.kt:373` 又是 `< 2024`。两个阈值未来会慢慢变成"全年无条件放行/永远不提示"。建议改成"设备时间与 HTTPS 响应 Date 头偏差超过 N 天"这类不随时间腐烂的判据。
**5. 快传文件目录多套一层 `uploads`,导致过期清理失效 & 无外置存储时崩溃**
- **路径嵌套错位**:`RemoteControlServer.kt:180` 写了 `File(context.getExternalFilesDir("uploads") ?: context.filesDir, "uploads")`。当 `getExternalFilesDir("uploads")` 非空时,其路径本身已是 `.../files/uploads`,再拼一层 `"uploads"` 会将文件实际存入 `.../files/uploads/uploads/`。
- **过期清理完全失效**:`BrowserUiHelper.cleanExpiredUploadFilesAsync`(`BrowserUiHelper.kt:204-219`)仅遍历 `getExternalFilesDir("uploads")` 一级目录下的 `f.isFile`,而子目录 `uploads` 的 `isFile` 为 `false`,导致启动时的「3 天过期上传文件清理」永远不会删掉任何文件。
- **内部存储回退崩溃**:当部分电视盒子无外置存储回退到 `context.filesDir/uploads` 时,`res/xml/file_paths.xml` 仅声明了 `<external-files-path>` 和 `<cache-path>`,缺少 `<files-path>`,此时 `RemoteControlHelper.promptInstallApk`(`RemoteControlHelper.kt:173-177`)调用 `FileProvider.getUriForFile` 会直接抛出 `IllegalArgumentException`。
**6. 快传视频使用 `Uri.fromFile` 导致调起外部播放器必崩(`FileUriExposedException`)**
- `RemoteControlHelper.handleFileUploaded`(`RemoteControlHelper.kt:162`)通过 `Uri.fromFile(file).toString()` 生成 `file://` 地址传给 `PlayerActivity`。
- 内置 ExoPlayer 可直接播放 `file://`,但若用户在播放器内点击「外部播放」(`PlayerActivity.launchExternalPlayer`,`:1319-1340`),在 Android 7.0+(本项目 `targetSdk = 34`)下向外部应用发送 `file://` URI 会直接抛出 `FileUriExposedException`(被 catch 后误提示为"未找到已安装的外部视频播放器")。应统一通过 `FileProvider` 生成 `content://` URI 并授予 `FLAG_GRANT_READ_URI_PERMISSION`。
**7. 进入 `PlayerActivity` 全屏看片时,手机遥控器的方向键/暂停/返回失效**
- `RemoteControlHelper` 目前强绑定在 `BrowserActivity` 上,其回调 `BrowserActivity.onRemoteKey`(`BrowserActivity.kt:880-897`)只向 `BrowserActivity` 分发按键。
- 当电视跳转到全屏 `PlayerActivity` 播放视频时,用户在手机遥控器网页上按下的方向键(快进/快退/呼出选集)、返回键、菜单键全部发往处于后台的 `BrowserActivity`,无法控制正在前台播放的 `PlayerActivity`。建议将遥控事件路由给当前处于前台 `RESUMED` 状态的 Activity。
---
## P1 · 构建、依赖与死代码清理(能直接减小体积、让换机器能编译)
| 问题 | 证据 | 影响 |
|---|---|---|
| 3 个依赖零引用 | `leanback 1.2.0-alpha04`、`material`、`constraintlayout` 在 Kotlin 与 layout 中均 0 处引用 | alpha 版 leanback 还把它的人声资源 `lb_voice_*.ogg`、`exo_ic_*` 打进 APK |
| `nanohttpd-websocket` 零引用 | 全仓库无 `NanoWSD` / `WebSocket`,`remote.html` 用的是 `XMLHttpRequest`(3 处) | 声明了双向通道依赖但从未实现 |
| **整文件死代码与废弃布局未清理** | `VideoWebActivity.kt`(及 `AndroidManifest.xml:54-58` 与 `activity_video_web.xml`)、`BookmarksDialog.kt`、`BookmarkAdapter.kt`(及 `dialog_bookmarks.xml`、`item_bookmark.xml`)、`video_control_bar.xml` 全工程 0 处引用 | 首页已改用网格卡片 `HomeCardAdapter`,旧版弹窗与 Web 播放页纯属历史遗留负重 |
| **死方法未清理** | `RemoteControlDialog.getLocalIpAddress()`(`:192-212`,实际调的是 `NetworkChecker`)、`VideoControlHelper` 中的 `JS_HAS_VIDEO`/`JS_GET_STATE`/`JS_GET_VIDEO_SRC`/`JS_GET_CONTENT_URL`、`PlaySource.listToJson`/`listFromJson`(`VideoSource.kt:42-56`) | 增加阅读与维护干扰 |
| 测试依赖声明了却没用 | `libs.versions.toml:34-36` 有 junit/junit-ext/espresso,`app/build.gradle.kts` 里从未引用,也不存在 `src/test`、`src/androidTest` 目录 | 见 P1「零测试」 |
| **缺少 Gradle Wrapper 脚本** | 只有 `gradle/wrapper/gradle-wrapper.properties`,`gradlew`、`gradlew.bat`、`gradle-wrapper.jar` 都不存在 | 换机器/CI 只能用 Android Studio 内建 Gradle,命令行无法构建 |
| 无 release 签名配置 | `app/build.gradle.kts:18-27` 的 `release {}` 没有 `signingConfig` | `assembleRelease` 产出未签名包 |
| 版本全线落后 | AGP 8.2.2 / Gradle 8.2 / Kotlin 1.9.22 / media3 1.3.1 / compileSdk 34 | `kotlinOptions` 已废弃;`targetSdk` 偏低在长期分发上会被卡 |
| `gradle.properties` 偏保守 | 只有 `-Xmx2048m`,无 `parallel` / `caching` / `configuration-cache` | 构建慢 |
| 1.4MB 的 `classes.jar` 提交进了仓库根目录 | `git ls-files` 可查 | 与项目无关的构建产物污染仓库 |
| 无 `.gitattributes` | 实测 62 个 CRLF 文件 / 19 个 LF 文件混用 | Windows 与其他平台协作会产生无意义 diff |
| 无 CI、无 `.github` | — | 没有最低限度的「编译 + lint」护栏 |
体积实测(`app-debug.apk`):压缩后 10.4MB,解压 21.35MB,含 **5 个 dex(9.2MB + 8.3MB 两个大 dex)**、`resources.arsc` 1.6MB。移除上面无用依赖与死代码 + 开启 R8 full mode 能实打实变小。
---
## P1 · 架构与可维护性(决定后续还能不能改)
**1. 两个巨型文件**
- `PlayerActivity.kt` **1717 行 / 68 个函数**:`dispatchTouchEvent` 单函数 114 行(1417-1537)、`onCreate` 75 行、`dispatchKeyEvent` 84 行、`handlePlayerError`/`resolveAndPlay`/`tryFailover` 交织。合理切法:`PlaybackSession`(源/线路/失败换线)、`EpisodeNavigator`(选集/预解析/连播)、`PlayerOsdController`(OSD/进度/网速/反馈)、`PlayerGestureHandler`(触摸手势)。
- `dialog_settings.xml` **880 行 / 110 个 view**,且 5 个面板全在一个文件里。建议按面板拆成 5 个 `View`/`include` 子布局。
**2. 83KB 内联 JavaScript 塞在 Kotlin 三引号字符串里**
`VideoSnifferHelper.kt` 28KB、`PurifyHelper.kt` 27KB、`ContentFilterHelper.kt` 12KB、`VideoControlHelper.kt` 3.8KB、`UserAgentHelper.kt` 2.9KB、`AdBlockHelper.kt` 2.8KB、`RemoteControlHelper.kt` 1.6KB。后果:没有语法高亮、不能单独 lint、改一次要重新编译 APK、无法在浏览器里独立调试、diff 全是噪音。建议迁到 `assets/js/*.js`,首次需要时读取 + 缓存,甚至可以做一次 `JS 端单测`(Node 跑一遍语法与关键规则)。
**3. 三份几乎相同的 JSON Repository(且 `UserScriptRepository` 漏了内存缓存)**
`BookmarkRepository.kt` / `HistoryRepository.kt` / `PlaybackHistoryRepository.kt` 是同一套「静态缓存 + `synchronized(Companion)` + `JSONArray` 全量重写 + `apply()`」的复制粘贴,约 200 行重复;而 `UserScriptRepository.kt` 甚至漏掉了静态内存缓存,每次 `onPageFinished` 都在主线程重读 SharedPreferences 并解析 JSON。抽 `JsonListStore<T>(prefsName, key, maxEntries, encode, decode)` 即可统一全部四个 Repository 的缓存、并发与裁剪策略。
**4. 设置项散落在 9 个 SharedPreferences 文件里**
`tv_browser_settings` 被 `BrowserActivity.kt:84` 和 `AppSettings.kt:12` **两处重复定义常量各自访问**;此外还有 `_adblock` / `_bookmarks` / `_content_filter` / `_history` / `_playback_history` / `_search_engine` / `_user_scripts` / `_vod_search`。建议收敛为一个 `SettingsRepository`(键集中 + 迁移版本号),`ContentFilterHelper`/`AdBlockHelper` 里那些 `@Volatile cachedXxx` 的手写缓存也可以统一。
**5. Activity 强耦合与进程重建状态丢失**
`SettingsDialog.kt` 里 12 处 `(activity as? BrowserActivity)?` 强耦合具体 Activity;**全项目 0 个 `onSaveInstanceState`**——进程被杀后,`PlayerActivity` 的选集/线路数据本来只存在内存 token 里(`PlayerActivity.kt:1619-1638`,为规避 Binder 限制的巧妙做法),此时无法直接恢复内存列表。可考虑落一个轻量 JSON 缓存文件存剧集页 + 集号 + 进度。
**6. 硬编码颜色与废弃资源条目**
| 指标 | 数量 |
|---|---|
| layout 里十六进制颜色 | **109 处**,19 种颜色,`#FFFFFF` 出现 30 次、`#38BDF8` 22 次、`#94A3B8` 20 次 |
| `colors.xml` 里真正被用到的 | 只有 `black` / `white` / `toolbar_bg`,`primary` / `accent` / `focus_highlight` / `url_bar_bg` 基本没人用 |
| `strings.xml` 废弃条目 | 78 条中有 **12 条从未被引用** |
建议清理未引用的 `strings.xml` / `colors.xml` 条目,并将高频的 `#FFFFFF`、`#38BDF8`、`#94A3B8` 等主题色抽入 `colors.xml` 统一管理。
**7. 默认影视站名单硬编码在代码里**
`BookmarkRepository.kt:111-141` 有 14 个默认站点 + 4 个版本的新增迁移表(`DEFAULTS_ADDED_BY_VERSION` 这个设计其实很好)。问题是站点会死,改一次要发版。建议把这套表挪到远端 JSON(失败时回落到内置表),迁移版本号机制可以原样保留。
**8. 小事但会误导用户**
`SettingsDialog.kt:386` 把 `"${BuildConfig.VERSION_NAME} (Release)"` 写死,debug 包也显示 "(Release)"。
---
## P1 · 零测试(这是最值得投资的"优化")
项目里 **`src/test` 与 `src/androidTest` 目录都不存在**,但代码中恰恰有一大批**纯函数、无 Android 依赖、非常好测**的点:
- `VideoSnifferHelper.isVideoStreamUrl` / `isHls` / `isMediaSegmentUrl` / `isLikelyVariantOf` / `buildPlaybackHeaders` / `isWebOnlyStream`(`:27-190`)
- `ContentFilterHelper.registrableDomain` / `shouldBlockImage` / `shouldBlockScript`
- `AdBlockHelper.shouldBlockUrl` / `isHijackScheme`(这里用 `lower.contains(pattern)` 的朴素子串匹配,误伤风险需要用例兜住)
- `PlaylistCache` 的 `isMaster` / `isVod` / `take` 语义(一次性链接取完即弃,逻辑微妙)
- `SiteInfo.normalizeUrl`、`AppSettings.seriesKey`、`PlaybackRecord.displaySubtitle` / `getProgressPercent`
这些写 30~50 个 JUnit 用例就能覆盖嗅探与过滤的核心判定,未来改规则时才敢动。这一步做完,`libs.versions.toml` 里躺着的 junit 依赖也就名正言顺了。
---
## P2 · 性能与资源
- **`onProgressChanged` 每个进度回调都注入一次脚本**:`BrowserActivity.kt:343-347` 只要 `hasDocStartHook == false`,每次进度变化都 `evaluateJavascript(JS_DOCUMENT_START_HOOK)`。这个 hook 是 20KB+ 的字符串(`VideoSnifferHelper.kt:213-435`),值得只在 `onPageStarted` 里做、并用一个"已注入"标志位替代 `hasDocStartHook` 的反复尝试。
- **`updateProgress()` 高频无差别干活太重**:`PlayerActivity.kt:1132-1183` 每 500ms 做 `TrafficStats.getUidRxBytes` + 新建 `Calendar` 实例 + 多次 `String.format`,并且每 5 秒走一次 `saveCurrentProgress()` → 整个播放历史 **全表 JSON 序列化 + `prefs.apply()`**(`PlaybackHistoryRepository.kt:180-199`)。建议改为仅在控制栏可见(`isOsdVisible()`)时才计算网速与格式化 UI 文本(并复用 `Calendar`),并将断点落盘改为 15~30 秒一次(配合 `onPause/onStop`/切集时必存),主线程压力会明显下降。
- **30 处 `Log.d` / `Log.v` 在 release 也打**,并且 `BrowserActivity.kt:355-358` 把**网页所有 console 消息**全量转进 Logcat。建议统一到一个 `Logx` 门面,由 `BuildConfig.DEBUG` 关闭。
- **崩溃日志无上限**:`TVBrowserApplication.kt:35-64` 每次崩溃写一个文件,从不清理,长期会占满 `filesDir`(而 `crash_logs` 里可能含 URL 等隐私)。加"保留最近 20 个 + 启动时清理"即可。
- **每 3 秒的常驻轮询**:`BrowserActivity.kt:138-144` 的 `remoteStatusChecker` 无条件改 `homeStatusBar.visibility = GONE`,可以在首页显示或退到后台时停掉这个轮询。
- **`notifyDataSetChanged` 9 处**(`HomeCardAdapter.kt:40,47`、`PlayerActivity.kt:915,937`、`VodSearchDialogHelper.kt:102` 等),选集动辄上百项,建议改 `DiffUtil` 或至少 `notifyItemRangeChanged`。
---
## P2 · 健壮性/代码卫生
- **19 处空 `catch` 静默吞异常**(`BrowserUiHelper.kt` 6 处、`VideoWebActivity.kt` 3 处、`RemoteControlDialog.kt` 2 处…),排查线上问题时这些会变成「什么都查不到」。
- **52 处 `catch (Exception/Throwable)`**、**23 处 `!!`**——大部分在 WebView/媒体回调里,可以换成更具体的异常 + 明确兜底。
- **`BrowserActivity.kt:318-330` 的新旧 `onReceivedError` 导致重复报错 Toast**:新版回调调用了 `super.onReceivedError(view, request, error)`,而系统默认实现会在主框架出错时转调旧版回调,导致主框架报错弹两次 Toast;且旧版回调未加 `API < 23` 守卫,低版本系统下子资源加载失败也会误弹 Toast。
- **零协程**:全项目 0 个 `lifecycleScope`/`suspend`,异步全靠 **17 处裸 `Thread { }` + 17 处 `postDelayed`**(`BrowserUiHelper.kt:365-385` 甚至在线程里调 `activity.getString`,Activity 已销毁时会崩)。引入 `lifecycleScope` 会同时解决可取消性与线程归属。
- **日志 TAG 分散**:`Log.d("TVBrowser.AdBlock", ...)`(`BrowserActivity.kt:239`)这类裸字符串与其他 `private const val TAG` 混用。
- 仓库习惯:**5 次提交全部同一天、提交信息是 `init` 和 4 个 `4k`**——即使单人也值得写清 commit message,否则半年后无法回溯「这个兜底为什么加」。
---
## 建议的落地顺序
1. **第一批(1~2 天,风险低收益高)**:删 4 个无用依赖 + 清理整文件死代码与废弃布局 + 补全 Gradle Wrapper + 提交 `classes.jar` 移出仓库 + 补 `.gitattributes` + 收紧 Manifest(`allowBackup`)+ 日志门控。产出:包变小、换机器能编译、代码库清爽。
2. **第二批(安全与核心 Bug 修复)**:修复快传 `uploads` 路径嵌套与 `file_paths.xml` + 修复快传视频 `FileProvider` 调外部播放器崩溃 + 支持 `PlayerActivity` 前台接收手机遥控按键 + 遥控去除通配 CORS 与同源 Token 校验 + `PlayerSsl` 作用域收敛 + 年份判定改造。
3. **第三批(可测性,收益长期)**:建 `src/test`,先把 `VideoSnifferHelper`/`ContentFilterHelper`/`PlaylistCache`/`SiteInfo` 的纯函数用 JUnit 锁住——**这一步做完再动嗅探规则才安全**。
4. **第四批(可维护性)**:83KB 内联 JS 迁 `assets/js`;抽 `JsonListStore` 统一四个 Repository(补齐 `UserScriptRepository` 缓存);设置项收敛到一个 `SettingsRepository`。
5. **第五批(重构,按需)**:`PlayerActivity` 按会话/选集/OSD/手势四块拆分;`dialog_settings.xml` 按面板拆分;高频颜色资源化。