优化
This commit is contained in:
@@ -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
|
||||
+11
@@ -1,7 +1,18 @@
|
||||
/build/
|
||||
.idea/
|
||||
.gradle/*
|
||||
.kotlin/
|
||||
*/release
|
||||
*/debug
|
||||
*/build/
|
||||
local.properties
|
||||
*.apk
|
||||
*.aab
|
||||
*.ap_
|
||||
*.dex
|
||||
*.class
|
||||
/classes.jar
|
||||
*.jks
|
||||
*.keystore
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
@@ -42,13 +42,9 @@ android {
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.constraintlayout)
|
||||
implementation(libs.leanback)
|
||||
implementation(libs.fragment.ktx)
|
||||
implementation(libs.recyclerview)
|
||||
implementation(libs.nanohttpd)
|
||||
implementation(libs.nanohttpd.websocket)
|
||||
implementation(libs.zxing.core)
|
||||
// 原生播放器:ExoPlayer(支持 HLS、自定义请求头、倍速)
|
||||
implementation(libs.media3.exoplayer)
|
||||
@@ -56,4 +52,6 @@ dependencies {
|
||||
implementation(libs.media3.ui)
|
||||
// 页面执行前注入脚本(可进入所有 iframe)
|
||||
implementation(libs.androidx.webkit)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<application
|
||||
android:name=".TVBrowserApplication"
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:hardwareAccelerated="true"
|
||||
android:banner="@drawable/banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -51,12 +51,6 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".VideoWebActivity"
|
||||
android:configChanges="orientation|screenSize"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.TVBrowser.NoActionBar" />
|
||||
|
||||
<activity
|
||||
android:name=".PlayerActivity"
|
||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize"
|
||||
@@ -65,7 +59,7 @@
|
||||
android:supportsPictureInPicture="true"
|
||||
android:theme="@style/Theme.TVBrowser.NoActionBar" />
|
||||
|
||||
<!-- 局域网快传 APK 自动安装 FileProvider -->
|
||||
<!-- 局域网快传 APK / 视频 FileProvider -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -354,6 +354,7 @@
|
||||
(function() {
|
||||
var base = window.location.origin;
|
||||
var cmdUrl = base + '/cmd';
|
||||
var tvToken = '__TV_SESSION_TOKEN__';
|
||||
var statusDot = document.getElementById('statusDot');
|
||||
var statusText = document.getElementById('statusText');
|
||||
|
||||
@@ -361,11 +362,12 @@
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', cmdUrl, true);
|
||||
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||
xhr.setRequestHeader('X-TV-Token', tvToken);
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4) {
|
||||
var ok = (xhr.status === 200);
|
||||
statusDot.classList.toggle('disconnected', !ok);
|
||||
statusText.textContent = ok ? '已连接电视' : '连接异常,请重试';
|
||||
statusText.textContent = ok ? '已连接电视' : '连接异常,请刷新页面重试';
|
||||
}
|
||||
};
|
||||
xhr.send(JSON.stringify(body));
|
||||
@@ -415,6 +417,7 @@
|
||||
var xhr = new XMLHttpRequest();
|
||||
var uploadUrl = base + '/upload?filename=' + encodeURIComponent(file.name);
|
||||
xhr.open('POST', uploadUrl, true);
|
||||
xhr.setRequestHeader('X-TV-Token', tvToken);
|
||||
|
||||
uploadProgressContainer.style.display = 'block';
|
||||
uploadProgressBar.style.width = '0%';
|
||||
@@ -443,7 +446,7 @@
|
||||
fileInput.value = '';
|
||||
}, 4000);
|
||||
} else {
|
||||
uploadProgressText.textContent = '❌ 传输失败,请重试';
|
||||
uploadProgressText.textContent = '❌ 传输失败,请检查文件格式或刷新重试';
|
||||
uploadProgressText.style.color = '#ef4444';
|
||||
}
|
||||
};
|
||||
@@ -523,7 +526,7 @@
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// 电脑端鼠标模拟测试
|
||||
// 电脑端鼠标模拟测试(加节流)
|
||||
var mouseMoved = false;
|
||||
touchpad.addEventListener('mousedown', function(e) {
|
||||
lastX = e.clientX;
|
||||
@@ -538,6 +541,8 @@
|
||||
lastY = e.clientY;
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
mouseMoved = true;
|
||||
if (moveThrottle) return;
|
||||
moveThrottle = setTimeout(function() { moveThrottle = 0; }, 30);
|
||||
send({ type: 'move', dx: dx, dy: dy });
|
||||
}
|
||||
});
|
||||
@@ -590,9 +595,15 @@
|
||||
var check = new XMLHttpRequest();
|
||||
check.open('POST', cmdUrl, true);
|
||||
check.setRequestHeader('Content-Type', 'application/json');
|
||||
check.setRequestHeader('X-TV-Token', tvToken);
|
||||
check.onload = function() {
|
||||
statusDot.classList.remove('disconnected');
|
||||
statusText.textContent = '已连接电视';
|
||||
if (check.status === 200) {
|
||||
statusDot.classList.remove('disconnected');
|
||||
statusText.textContent = '已连接电视';
|
||||
} else {
|
||||
statusDot.classList.add('disconnected');
|
||||
statusText.textContent = '连接校验失败,请刷新页面';
|
||||
}
|
||||
};
|
||||
check.onerror = function() {
|
||||
statusDot.classList.add('disconnected');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.tv.browser
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.WebResourceResponse
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
@@ -97,12 +96,8 @@ object AdBlockHelper {
|
||||
if (lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("about:") || lower.startsWith("javascript:")) {
|
||||
return false
|
||||
}
|
||||
val scheme = try {
|
||||
Uri.parse(url).scheme?.lowercase() ?: ""
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
return BLOCKED_SCHEMES.any { s -> s.equals(scheme, ignoreCase = true) }
|
||||
val scheme = lower.substringBefore(':', "")
|
||||
return scheme.isNotEmpty() && BLOCKED_SCHEMES.any { s -> s.equals(scheme, ignoreCase = true) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -107,7 +107,10 @@ object AppSettings {
|
||||
/** 剧集分组 key:站点主域 + 剧名(同一站点同一部剧共用片头片尾设置与追剧记录) */
|
||||
fun seriesKey(pageUrl: String?, seriesTitle: String): String {
|
||||
if (seriesTitle.isBlank()) return ""
|
||||
val host = pageUrl?.let { runCatching { android.net.Uri.parse(it).host }.getOrNull() }
|
||||
val 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 ""
|
||||
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.SharedPreferences
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 使用内存缓存 + SharedPreferences 持久化收藏列表。
|
||||
* 使用 [JsonListStore](内存缓存 + SharedPreferences)持久化收藏列表。
|
||||
*/
|
||||
class BookmarkRepository(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val keyBookmarks = "bookmarks"
|
||||
private val keyInitialized = "bookmarks_initialized"
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
fun getAll(): List<Bookmark> {
|
||||
cachedBookmarks?.let { return it }
|
||||
synchronized(Companion) {
|
||||
cachedBookmarks?.let { return it }
|
||||
val isInitialized = prefs.getBoolean(keyInitialized, false)
|
||||
val json = prefs.getString(keyBookmarks, null)
|
||||
if (!isInitialized && json == null) {
|
||||
save(DEFAULT_BOOKMARKS)
|
||||
prefs.edit()
|
||||
.putBoolean(keyInitialized, true)
|
||||
.putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION)
|
||||
.apply()
|
||||
cachedBookmarks = DEFAULT_BOOKMARKS
|
||||
return DEFAULT_BOOKMARKS
|
||||
}
|
||||
val list = parse(json)
|
||||
val migrated = migrateDefaults(list)
|
||||
cachedBookmarks = migrated
|
||||
return migrated
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
fun getAll(): List<Bookmark> = store.getAll(appContext) { prefs, parsed ->
|
||||
val isInitialized = prefs.getBoolean(KEY_INITIALIZED, false)
|
||||
val rawJson = prefs.getString(KEY_BOOKMARKS, null)
|
||||
if (!isInitialized && rawJson == null) {
|
||||
store.save(appContext, DEFAULT_BOOKMARKS)
|
||||
prefs.edit()
|
||||
.putBoolean(KEY_INITIALIZED, true)
|
||||
.putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION)
|
||||
.apply()
|
||||
DEFAULT_BOOKMARKS
|
||||
} else {
|
||||
migrateDefaults(prefs, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
/** 老用户升级:把之后版本新增的默认站点补到最前面(只补一次) */
|
||||
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)
|
||||
if (version >= DEFAULTS_VERSION) return list
|
||||
val added = (version + 1..DEFAULTS_VERSION)
|
||||
.flatMap { DEFAULTS_ADDED_BY_VERSION[it].orEmpty() }
|
||||
.filter { b -> list.none { it.url == b.url } }
|
||||
val result = if (added.isEmpty()) list else added + list
|
||||
if (added.isNotEmpty()) save(result)
|
||||
if (added.isNotEmpty()) store.save(appContext, result)
|
||||
prefs.edit().putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION).apply()
|
||||
return result
|
||||
}
|
||||
@@ -71,38 +43,38 @@ class BookmarkRepository(context: Context) {
|
||||
val list = getAll().toMutableList()
|
||||
list.removeAll { it.url == bookmark.url }
|
||||
list.add(0, bookmark)
|
||||
save(list)
|
||||
store.save(appContext, list)
|
||||
}
|
||||
|
||||
fun remove(url: String) {
|
||||
val list = getAll().filter { it.url != url }
|
||||
save(list)
|
||||
store.save(appContext, list)
|
||||
}
|
||||
|
||||
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 {
|
||||
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_TITLE = "title"
|
||||
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] 登记 */
|
||||
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.net.Uri
|
||||
import android.net.http.SslError
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -19,7 +20,6 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.webkit.ConsoleMessage
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.SslErrorHandler
|
||||
@@ -35,17 +35,13 @@ import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.webkit.ScriptHandler
|
||||
import com.tv.browser.databinding.ActivityBrowserBinding
|
||||
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 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 绑定) */
|
||||
fun currentUserAgent(context: Context): String {
|
||||
@@ -104,6 +100,8 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
private var isEditingCards: Boolean = false
|
||||
private var currentPageTitle: String = ""
|
||||
private var hasDocStartHook: Boolean = false
|
||||
/** 当设备不支持 WebViewCompat DOCUMENT_START 注入时,每个页面仅在加载早期注入一次兜底脚本,避免 onProgressChanged 反复注入 20KB 脚本 */
|
||||
private var pageDocStartInjected: Boolean = false
|
||||
private var desktopViewportScript: ScriptHandler? = null
|
||||
|
||||
internal lateinit var binding: ActivityBrowserBinding
|
||||
@@ -137,8 +135,11 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val remoteStatusChecker = object : Runnable {
|
||||
override fun run() {
|
||||
if (!isActivityResumed) return
|
||||
remoteHelper.updateStatusDot(binding.remoteStatusDot)
|
||||
binding.homeStatusBar.visibility = View.GONE
|
||||
if (binding.homeStatusBar.visibility != View.GONE) {
|
||||
binding.homeStatusBar.visibility = View.GONE
|
||||
}
|
||||
mainHandler.postDelayed(this, 3000)
|
||||
}
|
||||
}
|
||||
@@ -183,14 +184,13 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
|
||||
remoteHelper.ensureStarted()
|
||||
remoteHelper.initStatusDot(binding.remoteStatusDot)
|
||||
mainHandler.post(remoteStatusChecker)
|
||||
|
||||
webView.addJavascriptInterface(FullscreenBridge(), "AndroidFullscreenBridge")
|
||||
webView.addJavascriptInterface(VideoBridge(), "AndroidVideoBridge")
|
||||
webView.addJavascriptInterface(NavGuardBridge(), "AndroidNavGuard")
|
||||
webView.addJavascriptInterface(PurifyBridge(), PurifyHelper.BRIDGE_NAME)
|
||||
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)
|
||||
currentZoomLevel = prefs.getInt(KEY_ZOOM_LEVEL, 100)
|
||||
@@ -236,7 +236,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||
val targetUrl = request?.url?.toString() ?: return false
|
||||
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
|
||||
}
|
||||
if (request.isForMainFrame == false) return false
|
||||
@@ -271,9 +271,13 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
progressBar.visibility = View.VISIBLE
|
||||
if (!isOnHomePage && url != "about:blank") urlBar.setText(url ?: "")
|
||||
currentPageTitle = ""
|
||||
pageDocStartInjected = false
|
||||
hideKeyboard()
|
||||
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)
|
||||
view?.evaluateJavascript(VideoControlHelper.JS_ENABLE_FULLSCREEN_ENHANCEMENT, 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")
|
||||
override fun onReceivedError(view: WebView?, errorCode: Int, description: String?, failingUrl: String?) {
|
||||
progressBar.visibility = View.GONE
|
||||
Toast.makeText(this@BrowserActivity, getString(R.string.error_page), Toast.LENGTH_SHORT).show()
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M && failingUrl == view?.url) {
|
||||
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?) {
|
||||
super.onReceivedError(view, request, error)
|
||||
if (request?.isForMainFrame == true) {
|
||||
progressBar.visibility = View.GONE
|
||||
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?) {
|
||||
val primary = error?.primaryError
|
||||
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
|
||||
Log.w(TAG_VIDEO, "onReceivedSslError: code=$primary allow=$allow url=${error?.url}")
|
||||
if (allow) handler?.proceed() else handler?.cancel()
|
||||
@@ -342,7 +347,10 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
webView.webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -353,7 +361,9 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -825,7 +835,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
@JavascriptInterface
|
||||
fun onPlaylist(url: String, finalUrl: String, text: String) {
|
||||
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
|
||||
@@ -834,7 +844,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
runOnUiThread {
|
||||
snifferController.currentSiteInfo = info
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -872,21 +882,34 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
runOnUiThread { cursorController.move(dx, dy, isOnHomePage) { setToolbarVisible(true) } }
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
override fun onRemoteScroll(dx: Int, dy: Int) = runOnUiThread { webView.scrollBy(dx, dy) }
|
||||
|
||||
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) {
|
||||
"back" -> onBackPressed()
|
||||
"home" -> showHomePage()
|
||||
"menu" -> if (!isOnHomePage && !isToolbarVisible) setToolbarVisible(true) else showMoreMenu()
|
||||
"refresh" -> webView.reload()
|
||||
"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_right" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT))
|
||||
"dpad_up" -> dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP))
|
||||
@@ -899,6 +922,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
override fun onRemoteOpenUrl(url: String) {
|
||||
val trimmed = url.trim().takeIf { it.isNotEmpty() } ?: return
|
||||
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
|
||||
urlBar.setText(targetUrl)
|
||||
loadUrl(targetUrl)
|
||||
@@ -909,8 +933,8 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
override fun onRemoteText(text: String) =
|
||||
runOnUiThread { remoteHelper.handleRemoteText(text, currentFocus, isOnHomePage, webView, urlBar) }
|
||||
|
||||
override fun onRemoteFileUploaded(file: File, filename: String) =
|
||||
runOnUiThread { remoteHelper.handleFileUploaded(file, filename) }
|
||||
override fun onRemoteFileUploaded(file: File, filename: String, clientIp: String) =
|
||||
runOnUiThread { remoteHelper.handleFileUploaded(file, filename, clientIp) }
|
||||
|
||||
internal fun showRemoteControlDialog() = RemoteControlDialog().show(supportFragmentManager, "remote")
|
||||
|
||||
@@ -946,11 +970,16 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
resumeWebMediaIfSuspended()
|
||||
}
|
||||
if (isOnHomePage && ::binding.isInitialized) updateContinueWatchingBanner()
|
||||
if (::binding.isInitialized) updateCursorOverlayVisibility()
|
||||
if (::binding.isInitialized) {
|
||||
updateCursorOverlayVisibility()
|
||||
mainHandler.removeCallbacks(remoteStatusChecker)
|
||||
mainHandler.post(remoteStatusChecker)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
isActivityResumed = false
|
||||
mainHandler.removeCallbacks(remoteStatusChecker)
|
||||
cancelAutoPlay(markHandled = true)
|
||||
if (::webView.isInitialized) {
|
||||
suspendWebMedia()
|
||||
@@ -961,6 +990,7 @@ class BrowserActivity : AppCompatActivity(), RemoteControlCallback {
|
||||
|
||||
override fun onStop() {
|
||||
isActivityResumed = false
|
||||
mainHandler.removeCallbacks(remoteStatusChecker)
|
||||
cancelAutoPlay(markHandled = true)
|
||||
if (::webView.isInitialized) {
|
||||
suspendWebMedia()
|
||||
|
||||
@@ -208,7 +208,8 @@ object BrowserUiHelper {
|
||||
try {
|
||||
val expireBefore = System.currentTimeMillis() - 3L * 24 * 3600 * 1000
|
||||
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) {
|
||||
f.delete()
|
||||
}
|
||||
@@ -355,30 +356,34 @@ object BrowserUiHelper {
|
||||
}
|
||||
|
||||
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)
|
||||
.setTitle(activity.getString(R.string.check_network))
|
||||
.setTitle(titleStr)
|
||||
.setMessage(activity.getString(R.string.network_checking))
|
||||
.setCancelable(false)
|
||||
.create()
|
||||
progress.show()
|
||||
|
||||
Thread {
|
||||
val linkStatus = NetworkChecker.getStatusMessage(activity)
|
||||
val linkStatus = NetworkChecker.getStatusMessage(appContext)
|
||||
val internetReachable = NetworkChecker.isInternetReachable()
|
||||
val internetStatus = if (internetReachable) {
|
||||
activity.getString(R.string.internet_ok)
|
||||
} else {
|
||||
activity.getString(R.string.internet_unreachable)
|
||||
}
|
||||
val internetStatus = if (internetReachable) okStr else unreachableStr
|
||||
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 {
|
||||
if (!activity.isFinishing && !activity.isDestroyed) {
|
||||
progress.dismiss()
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle(activity.getString(R.string.check_network))
|
||||
.setTitle(titleStr)
|
||||
.setMessage("$linkStatus\n$internetStatus$timeTip")
|
||||
.setPositiveButton(activity.getString(R.string.close), null)
|
||||
.setPositiveButton(closeStr, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,16 @@
|
||||
package com.tv.browser
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* 浏览历史持久化(内存缓存 + SharedPreferences 异步落盘),最多保留 [MAX_ENTRIES] 条。
|
||||
* 浏览历史持久化(基于 [JsonListStore] 内存缓存 + SharedPreferences 异步落盘),最多保留 [MAX_ENTRIES] 条。
|
||||
*/
|
||||
class HistoryRepository(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val keyHistory = "history"
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
fun getAll(): List<HistoryItem> {
|
||||
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
|
||||
}
|
||||
}
|
||||
fun getAll(): List<HistoryItem> = store.getAll(appContext)
|
||||
|
||||
/** 添加一条记录:同 URL 只保留最新,总数不超过 [MAX_ENTRIES] */
|
||||
fun add(url: String, title: String) {
|
||||
@@ -47,39 +19,38 @@ class HistoryRepository(context: Context) {
|
||||
val list = getAll().toMutableList()
|
||||
list.removeAll { it.url == url }
|
||||
list.add(0, HistoryItem(url = url, title = title.trim(), visitedAt = now))
|
||||
save(list.take(MAX_ENTRIES))
|
||||
store.save(appContext, list)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
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()
|
||||
}
|
||||
}
|
||||
fun clear() = store.clear(appContext)
|
||||
|
||||
companion object {
|
||||
private const val PREFS_NAME = "tv_browser_history"
|
||||
private const val KEY_HISTORY = "history"
|
||||
private const val KEY_URL = "url"
|
||||
private const val KEY_TITLE = "title"
|
||||
private const val KEY_VISITED_AT = "visitedAt"
|
||||
const val MAX_ENTRIES = 50
|
||||
@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.NetworkInterface
|
||||
import java.net.URL
|
||||
import java.util.Calendar
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* 检查网络连接状态、系统验证结果与互联网实际可达性。
|
||||
* 检查网络连接状态、系统验证结果、互联网实际可达性与系统时钟偏差。
|
||||
*/
|
||||
object NetworkChecker {
|
||||
|
||||
private const val INTERNET_CHECK_URL = "https://www.baidu.com"
|
||||
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 表示超时或失败。
|
||||
*/
|
||||
fun isInternetReachable(): Boolean {
|
||||
return try {
|
||||
val url = URL(INTERNET_CHECK_URL)
|
||||
val conn = url.openConnection() as? HttpURLConnection ?: return false
|
||||
if (conn is javax.net.ssl.HttpsURLConnection) {
|
||||
conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
|
||||
val trustAll = arrayOf<javax.net.ssl.TrustManager>(
|
||||
object : javax.net.ssl.X509TrustManager {
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate>? = null
|
||||
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
|
||||
}
|
||||
)
|
||||
val sc = javax.net.ssl.SSLContext.getInstance("TLS")
|
||||
sc.init(null, trustAll, java.security.SecureRandom())
|
||||
conn.sslSocketFactory = sc.socketFactory
|
||||
}
|
||||
PlayerSsl.applyTo(conn, lenient = true)
|
||||
conn.requestMethod = "HEAD"
|
||||
conn.connectTimeout = INTERNET_CHECK_TIMEOUT_MS
|
||||
conn.readTimeout = INTERNET_CHECK_TIMEOUT_MS
|
||||
conn.instanceFollowRedirects = true
|
||||
val code = conn.responseCode
|
||||
updateServerDate(conn.date)
|
||||
conn.disconnect()
|
||||
code in 200..399
|
||||
} catch (_: Exception) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package com.tv.browser
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 视频播放记录项:用于断点续播与继续观看推荐
|
||||
@@ -50,51 +49,21 @@ data class PlaybackRecord(
|
||||
val min = (totalSec / 60) % 60
|
||||
val hour = totalSec / 3600
|
||||
return if (hour > 0) {
|
||||
String.format("%02d:%02d:%02d", hour, min, sec)
|
||||
String.format(Locale.US, "%02d:%02d:%02d", hour, min, sec)
|
||||
} else {
|
||||
String.format("%02d:%02d", min, sec)
|
||||
String.format(Locale.US, "%02d:%02d", min, sec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放历史持久化管理(内存缓存 + SharedPreferences 异步落盘,避免播放中高频重复解析 JSON)
|
||||
* 播放历史持久化管理(基于 [JsonListStore] 内存缓存 + SharedPreferences 异步落盘,避免播放中高频重复解析 JSON)
|
||||
*/
|
||||
class PlaybackHistoryRepository(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
fun getAll(): List<PlaybackRecord> {
|
||||
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
|
||||
}
|
||||
}
|
||||
fun getAll(): List<PlaybackRecord> = store.getAll(appContext)
|
||||
|
||||
/**
|
||||
* 保存播放进度:若播放时间超过 5 秒且未播完,则保存或更新。
|
||||
@@ -115,7 +84,7 @@ class PlaybackHistoryRepository(context: Context) {
|
||||
|
||||
// 如果已经播到最后 10 秒,视为播放结束,不再存为断点
|
||||
if (durationMs > 0 && positionMs >= durationMs - 10000) {
|
||||
save(list)
|
||||
store.save(appContext, list)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -130,8 +99,7 @@ class PlaybackHistoryRepository(context: Context) {
|
||||
episodeName = episodeName
|
||||
)
|
||||
list.add(0, record)
|
||||
while (list.size > MAX_RECORDS) list.removeAt(list.size - 1)
|
||||
save(list)
|
||||
store.save(appContext, list)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,8 +110,7 @@ class PlaybackHistoryRepository(context: Context) {
|
||||
val list = getAll().toMutableList()
|
||||
list.removeAll { it.url == url || it.series == series }
|
||||
list.add(0, PlaybackRecord(url, title.ifBlank { seriesTitle }, 0, 0, System.currentTimeMillis(), series, seriesTitle, episodeName))
|
||||
while (list.size > MAX_RECORDS) list.removeAt(list.size - 1)
|
||||
save(list)
|
||||
store.save(appContext, list)
|
||||
}
|
||||
|
||||
/** 获取最近一条未看完的播放记录 */
|
||||
@@ -167,23 +134,22 @@ class PlaybackHistoryRepository(context: Context) {
|
||||
fun remove(url: String) {
|
||||
val list = getAll().toMutableList()
|
||||
list.removeAll { it.url == url }
|
||||
save(list)
|
||||
store.save(appContext, list)
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
synchronized(Companion) {
|
||||
cachedRecords = emptyList()
|
||||
prefs.edit().remove(KEY_RECORDS).apply()
|
||||
}
|
||||
}
|
||||
fun clearAll() = store.clear(appContext)
|
||||
|
||||
private fun save(list: List<PlaybackRecord>) {
|
||||
val snapshot = list.toList()
|
||||
synchronized(Companion) {
|
||||
cachedRecords = snapshot
|
||||
val arr = JSONArray()
|
||||
snapshot.forEach { r ->
|
||||
arr.put(JSONObject().apply {
|
||||
companion object {
|
||||
private const val PREFS_NAME = "tv_browser_playback_history"
|
||||
private const val KEY_RECORDS = "records"
|
||||
private const val MAX_RECORDS = 30
|
||||
|
||||
private val store = JsonListStore<PlaybackRecord>(
|
||||
prefsName = PREFS_NAME,
|
||||
key = KEY_RECORDS,
|
||||
maxEntries = MAX_RECORDS,
|
||||
encode = { r ->
|
||||
JSONObject().apply {
|
||||
put("url", r.url)
|
||||
put("title", r.title)
|
||||
put("positionMs", r.positionMs)
|
||||
@@ -192,17 +158,21 @@ class PlaybackHistoryRepository(context: Context) {
|
||||
if (r.series.isNotBlank()) put("series", r.series)
|
||||
if (r.seriesTitle.isNotBlank()) put("seriesTitle", r.seriesTitle)
|
||||
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.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.net.TrafficStats
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import android.util.Rational
|
||||
import android.view.KeyEvent
|
||||
@@ -21,6 +23,7 @@ import android.widget.SeekBar
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
@@ -29,18 +32,18 @@ import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
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.RecyclerView
|
||||
import java.util.Calendar
|
||||
import com.tv.browser.databinding.ActivityPlayerBinding
|
||||
import com.tv.browser.databinding.ItemEpisodeBinding
|
||||
import java.io.File
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import kotlin.math.ceil
|
||||
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 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 var currentSpeedIndex = 0
|
||||
|
||||
@@ -272,7 +276,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
binding.tvStatus.visibility = View.GONE
|
||||
stallHintShown = false
|
||||
readySinceReResolve = true
|
||||
updateProgress()
|
||||
updateProgress(forceUi = true)
|
||||
maybeSkipIntro()
|
||||
failedSources.clear()
|
||||
updatePauseOverlay()
|
||||
@@ -393,7 +397,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
.setReadTimeoutMs(PlayerTuning.READ_TIMEOUT_MS)
|
||||
// 清单命中缓存时直接用网页下载过的内容(一次性 m3u8 链接再请求会失效),分片等仍走网络
|
||||
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)
|
||||
if (source.isHls) itemBuilder.setMimeType(MimeTypes.APPLICATION_M3U8)
|
||||
@@ -420,7 +424,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
if (!canPlayNow) p.pause()
|
||||
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
|
||||
suppressSave = false
|
||||
introChecked = false
|
||||
@@ -563,7 +567,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
if (pre != null && pre.index == index && pre.pageUrl == ep.url &&
|
||||
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()
|
||||
currentPageUrl = ep.url
|
||||
updateEpisodeIndex()
|
||||
@@ -654,7 +658,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
binding.tvVideoTitle.text = videoTitle
|
||||
updateEpisodeUi()
|
||||
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)
|
||||
return true
|
||||
}
|
||||
@@ -689,13 +693,13 @@ class PlayerActivity : AppCompatActivity() {
|
||||
preloadingIndex = nextIndex
|
||||
val r = preloadResolver ?: VideoResolver(this, binding.resolverHost, BrowserActivity.currentUserAgent(this))
|
||||
.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 {
|
||||
override fun onResolved(source: VideoSource, siteInfo: SiteInfo?) {
|
||||
if (preloadingIndex != nextIndex) return
|
||||
preloadingIndex = -1
|
||||
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) {
|
||||
@@ -733,7 +737,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
if (outro <= 0 || outro >= duration) return
|
||||
if (current >= duration - outro) {
|
||||
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("已跳过片尾")
|
||||
onPlaybackEnded()
|
||||
}
|
||||
@@ -843,7 +847,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
appendLine("🎬 视频标题:$videoTitle")
|
||||
if (seriesTitle.isNotBlank()) appendLine("📺 剧集归属:$seriesTitle")
|
||||
appendLine("⏱️ 播放进度:${formatTime(cur)} / ${if (dur > 0) formatTime(dur) else "--:--"}")
|
||||
appendLine("🌐 实时网速:$currentSpeedStr")
|
||||
appendLine("🌐 实时网速:${updateNetworkSpeed()}")
|
||||
appendLine("📦 已缓冲时长:${bufferedAheadSec} 秒 (${p?.bufferedPercentage ?: 0}%)")
|
||||
val down = diagnostics.downloadBytesPerSec
|
||||
val need = diagnostics.mediaBytesPerSec
|
||||
@@ -1052,7 +1056,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
p.seekTo(target)
|
||||
val sign = if (offsetMs > 0) "+" else ""
|
||||
showFeedback("$sign${offsetMs / 1000}s (${formatTime(target)})")
|
||||
updateProgress()
|
||||
updateProgress(forceUi = true)
|
||||
}
|
||||
|
||||
/** 长按方向键加速:10s → 30s → 60s */
|
||||
@@ -1083,7 +1087,9 @@ class PlayerActivity : AppCompatActivity() {
|
||||
private fun isOsdVisible() = binding.playerOsd.visibility == View.VISIBLE
|
||||
|
||||
private fun showOsdTemporarily(focusPlay: Boolean = false) {
|
||||
val wasHidden = binding.playerOsd.visibility != View.VISIBLE
|
||||
binding.playerOsd.visibility = View.VISIBLE
|
||||
if (wasHidden) updateProgress(forceUi = true)
|
||||
if (focusPlay) binding.btnPlayPause.requestFocus()
|
||||
mainHandler.removeCallbacks(hideOsdRunnable)
|
||||
// 正在播放时 5 秒后自动隐藏;暂停时控制栏常驻显示,方便查看进度与操作
|
||||
@@ -1129,18 +1135,11 @@ class PlayerActivity : AppCompatActivity() {
|
||||
return currentSpeedStr
|
||||
}
|
||||
|
||||
private fun updateProgress() {
|
||||
private fun updateProgress(forceUi: Boolean = false) {
|
||||
val p = player ?: return
|
||||
val current = p.currentPosition.coerceAtLeast(0L)
|
||||
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()) {
|
||||
diagnostics.stallHint()?.let {
|
||||
@@ -1152,26 +1151,41 @@ class PlayerActivity : AppCompatActivity() {
|
||||
binding.tvStatus.visibility = View.GONE
|
||||
}
|
||||
|
||||
binding.tvCurrentTime.text = formatTime(current)
|
||||
if (duration != C.TIME_UNSET && duration > 0) {
|
||||
binding.tvTotalTime.text = formatTime(duration)
|
||||
binding.playerSeekBar.progress = ((current * 1000) / duration).toInt()
|
||||
val bufferPercent = p.bufferedPercentage
|
||||
val remainingMs = (duration - current).coerceAtLeast(0L)
|
||||
val remainingStr = formatTime(remainingMs)
|
||||
val finishCal = Calendar.getInstance().apply { add(Calendar.MILLISECOND, remainingMs.toInt()) }
|
||||
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 播完"
|
||||
} else {
|
||||
binding.tvTotalTime.text = "--:--"
|
||||
binding.tvPlaybackInfo.text = "网速: $speed · 缓冲: ${bufferedAheadSec}秒 · 已播放: ${formatTime(current)}"
|
||||
// 仅当控制栏可见(或显式刷新)时才计算网速与格式化 UI 文案,降低主线程每 500ms 的开销
|
||||
if (forceUi || isOsdVisible()) {
|
||||
val bufferedPos = p.bufferedPosition.coerceAtLeast(0L)
|
||||
val bufferedAheadSec = ((bufferedPos - current).coerceAtLeast(0L)) / 1000L
|
||||
val rawSpeed = updateNetworkSpeed()
|
||||
val speed = buildString {
|
||||
append(if (!p.isLoading && bufferedAheadSec >= 10) "缓冲充足,暂停下载" else rawSpeed)
|
||||
diagnostics.summary()?.let { append("(").append(it).append(")") }
|
||||
}
|
||||
binding.tvCurrentTime.text = formatTime(current)
|
||||
if (duration != C.TIME_UNSET && duration > 0) {
|
||||
binding.tvTotalTime.text = formatTime(duration)
|
||||
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
|
||||
|
||||
// 每隔 5 秒自动持久化保存一次播放断点进度
|
||||
// 每隔 15 秒自动持久化保存一次播放断点进度(暂停/切集/退出时立即保存)
|
||||
val currentSec = current / 1000
|
||||
if (currentSec >= 5 && currentSec - lastSavedSec >= 5) {
|
||||
if (currentSec >= 5 && currentSec - lastSavedSec >= 15) {
|
||||
lastSavedSec = currentSec
|
||||
saveCurrentProgress()
|
||||
}
|
||||
@@ -1249,7 +1263,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
) {
|
||||
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||
if (isInPictureInPictureMode) {
|
||||
pipInstanceRef = java.lang.ref.WeakReference(this)
|
||||
pipInstanceRef = WeakReference(this)
|
||||
pipEnteredAt = android.os.SystemClock.uptimeMillis()
|
||||
hideOsd()
|
||||
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() {
|
||||
val source = currentSource
|
||||
if (source == null) {
|
||||
@@ -1323,14 +1337,24 @@ class PlayerActivity : AppCompatActivity() {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
// MX Player 等支持的请求头格式:[key1, value1, key2, value2, ...]
|
||||
if (source.headers.isNotEmpty()) {
|
||||
putExtra("headers", source.headers.flatMap { listOf(it.key, it.value) }.toTypedArray())
|
||||
}
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
if (targetUri.scheme == "content") {
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, "选择外部播放器"))
|
||||
} 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
|
||||
if (ev.actionMasked == android.view.MotionEvent.ACTION_UP) {
|
||||
player?.seekTo(gestureTargetPos)
|
||||
updateProgress()
|
||||
updateProgress(forceUi = true)
|
||||
}
|
||||
return true
|
||||
} else if (mode == 2 || mode == 3) {
|
||||
@@ -1556,6 +1625,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
activeInstance = WeakReference(this)
|
||||
isInBackground = false
|
||||
hideSystemUI()
|
||||
if (currentSource != null) {
|
||||
@@ -1568,6 +1638,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
super.onPause()
|
||||
val inPip = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) isInPictureInPictureMode else false
|
||||
if (!inPip) {
|
||||
if (activeInstance?.get() === this) activeInstance = null
|
||||
isInBackground = true
|
||||
pauseForBackground()
|
||||
} else {
|
||||
@@ -1577,6 +1648,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (activeInstance?.get() === this) activeInstance = null
|
||||
isInBackground = true
|
||||
mainHandler.removeCallbacks(updateProgressRunnable)
|
||||
cancelPreload()
|
||||
@@ -1585,7 +1657,9 @@ class PlayerActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (activeInstance?.get() === this) activeInstance = null
|
||||
if (pipInstanceRef?.get() === this) pipInstanceRef = null
|
||||
PlayerSsl.restoreSystemDefaults()
|
||||
saveCurrentProgress()
|
||||
mainHandler.removeCallbacksAndMessages(null)
|
||||
resolver?.destroy()
|
||||
@@ -1600,6 +1674,10 @@ class PlayerActivity : AppCompatActivity() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PlayerActivity"
|
||||
/** 当前处于前台活动状态的 PlayerActivity,用于接收局域网手机遥控按键 */
|
||||
@Volatile
|
||||
internal var activeInstance: WeakReference<PlayerActivity>? = null
|
||||
|
||||
/** 选集格子目标宽度(dp),列数 = 屏幕宽度 / 该值,限制在 4~10 列 */
|
||||
private const val EPISODE_CELL_DP = 110
|
||||
/** 距结尾多久开始预解析下一集 */
|
||||
@@ -1645,7 +1723,7 @@ class PlayerActivity : AppCompatActivity() {
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -1677,8 +1755,8 @@ class PlayerActivity : AppCompatActivity() {
|
||||
/** 加密流(央视网等)原生播放会花屏:拒绝启动并提示 */
|
||||
private fun rejectWebOnly(context: Context, url: String): Boolean {
|
||||
if (!VideoSnifferHelper.isWebOnlyStream(url)) return false
|
||||
Log.d(TAG, "reject web-only stream: $url")
|
||||
android.widget.Toast.makeText(context, VideoSnifferHelper.WEB_ONLY_MESSAGE, android.widget.Toast.LENGTH_LONG).show()
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "reject web-only stream: $url")
|
||||
Toast.makeText(context, VideoSnifferHelper.WEB_ONLY_MESSAGE, Toast.LENGTH_LONG).show()
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -2,32 +2,61 @@ package com.tv.browser
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.util.Log
|
||||
import java.net.HttpURLConnection
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.CertPathValidatorException
|
||||
import java.security.cert.CertificateException
|
||||
import java.security.cert.X509Certificate
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLException
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* 原生播放器的 SSL 兼容处理。
|
||||
* 原生播放器与内部网络请求的 SSL 兼容管理。
|
||||
*
|
||||
* 网页里 WebView 遇到证书错误会继续加载(见 onReceivedSslError),但 ExoPlayer 走的是系统 HttpURLConnection,
|
||||
* 证书过期 / 旧电视缺少新根证书(如 Let's Encrypt ISRG Root X1)/ 电视时间不准时会直接报
|
||||
* ERROR_CODE_IO_NETWORK_CONNECTION_FAILED。这里在检测到 SSL 类错误后开启"宽松模式",与网页行为保持一致。
|
||||
*
|
||||
* 注意:HttpsURLConnection 的默认工厂是进程级的,开启后本进程内的 HttpURLConnection 请求都不再校验证书
|
||||
* (WebView 不受影响)。只在播放器确实遇到 SSL 错误时才开启。
|
||||
* 1. 保存进程启动时的系统默认 SSLSocketFactory 与 HostnameVerifier,确保宽松模式可随时回滚恢复,不再永久污染全局。
|
||||
* 2. 提供单连接级 [applyTo] 方法,供 [VodSearchHelper] 与 [NetworkChecker] 按需独立配置,避免重复定义 TrustManager。
|
||||
* 3. 播放器遇到证书错误时按需调用 [enableLenient] 并在切源/退出播放器时调用 [restoreSystemDefaults] 恢复系统默认校验。
|
||||
*/
|
||||
object 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
|
||||
var lenient: Boolean = false
|
||||
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 握手问题 */
|
||||
fun isSslError(error: Throwable?): Boolean {
|
||||
var e = error
|
||||
@@ -54,27 +83,33 @@ object PlayerSsl {
|
||||
return if (msg.isBlank()) e.javaClass.simpleName else "${e.javaClass.simpleName}: $msg"
|
||||
}
|
||||
|
||||
/** 开启宽松模式:信任所有证书、不校验主机名。返回 true 表示本次新开启 */
|
||||
@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager", "BadHostnameVerifier")
|
||||
/** 为当前播放会话开启宽松模式;返回 true 表示本次新开启 */
|
||||
@Synchronized
|
||||
fun enableLenient(): Boolean {
|
||||
if (lenient) return false
|
||||
return try {
|
||||
val trustAll = object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {}
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||
}
|
||||
val ctx = SSLContext.getInstance("TLS")
|
||||
ctx.init(null, arrayOf<TrustManager>(trustAll), java.security.SecureRandom())
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(ctx.socketFactory)
|
||||
HttpsURLConnection.setDefaultHostnameVerifier { _, _ -> true }
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(lenientSslSocketFactory)
|
||||
HttpsURLConnection.setDefaultHostnameVerifier(lenientHostnameVerifier)
|
||||
lenient = true
|
||||
Log.w(TAG, "lenient SSL enabled for native player")
|
||||
Log.w(TAG, "lenient SSL enabled for current playback session")
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "enableLenient failed", e)
|
||||
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 {
|
||||
val entry = map[url] ?: return false
|
||||
if (!entry.isMaster) return true
|
||||
val base = Uri.parse(entry.finalUrl)
|
||||
return entry.text.lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||
.any { line -> map.containsKey(resolve(base, line)) }
|
||||
.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 {
|
||||
java.net.URI(base.toString()).resolve(ref).toString()
|
||||
java.net.URI(baseUrl).resolve(ref).toString()
|
||||
} catch (_: Exception) {
|
||||
ref
|
||||
}
|
||||
@@ -75,6 +74,9 @@ object PlaylistCache {
|
||||
if (!entry.isMaster && !entry.isVod) map.remove(url)
|
||||
return entry
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() = map.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,5 +17,5 @@ interface RemoteControlCallback {
|
||||
/** 推送网址:在电视端立即打开并加载该网址 */
|
||||
fun onRemoteOpenUrl(url: String)
|
||||
/** 局域网快传:手机端向电视上传文件(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 com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
@@ -189,28 +187,6 @@ class RemoteControlDialog : DialogFragment() {
|
||||
return ipPattern.matcher(s).matches()
|
||||
}
|
||||
|
||||
private fun getLocalIpAddress(): String? {
|
||||
try {
|
||||
val interfaces = NetworkInterface.getNetworkInterfaces()?.toList() ?: return null
|
||||
val sorted = interfaces.sortedByDescending { ni ->
|
||||
when {
|
||||
ni.name.startsWith("wlan", ignoreCase = true) -> 2
|
||||
ni.name.startsWith("eth", ignoreCase = true) -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
for (ni in sorted) {
|
||||
if (ni.isLoopback || !ni.isUp) continue
|
||||
for (addr in ni.inetAddresses) {
|
||||
if (addr is Inet4Address && !addr.isLoopbackAddress && !addr.hostAddress.orEmpty().startsWith("127.")) {
|
||||
return addr.hostAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
return null
|
||||
}
|
||||
|
||||
private fun generateQrBitmap(content: String, sizePx: Int): Bitmap {
|
||||
val writer = QRCodeWriter()
|
||||
val bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, sizePx, sizePx)
|
||||
|
||||
@@ -150,51 +150,62 @@ class RemoteControlHelper(
|
||||
}
|
||||
|
||||
/** 处理手机快传上传的文件(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()
|
||||
if (lower.endsWith(".apk")) {
|
||||
promptInstallApk(file, filename)
|
||||
} else if (lower.endsWith(".mp4") || lower.endsWith(".m3u8") || lower.endsWith(".mkv") || lower.endsWith(".flv")) {
|
||||
AlertDialog.Builder(activity)
|
||||
promptInstallApk(targetActivity, file, filename, clientIp)
|
||||
} else if (lower.endsWith(".mp4") || lower.endsWith(".m3u8") || lower.endsWith(".mkv") || lower.endsWith(".flv") || lower.endsWith(".ts") || lower.endsWith(".webm")) {
|
||||
val contentUri = try {
|
||||
FileProvider.getUriForFile(
|
||||
targetActivity,
|
||||
"${targetActivity.packageName}.fileprovider",
|
||||
file
|
||||
).toString()
|
||||
} catch (_: Exception) {
|
||||
Uri.fromFile(file).toString()
|
||||
}
|
||||
AlertDialog.Builder(targetActivity)
|
||||
.setTitle("🎬 收到手机快传视频")
|
||||
.setMessage("文件:$filename\n大小:${file.length() / 1024 / 1024}MB\n是否立即全屏播放?")
|
||||
.setPositiveButton("立即播放") { _, _ ->
|
||||
PlayerActivity.start(activity, Uri.fromFile(file).toString(), filename)
|
||||
PlayerActivity.start(targetActivity, contentUri, filename)
|
||||
}
|
||||
.setNegativeButton("稍后", null)
|
||||
.show()
|
||||
} 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 {
|
||||
val uri = FileProvider.getUriForFile(
|
||||
activity,
|
||||
"${activity.packageName}.fileprovider",
|
||||
hostActivity,
|
||||
"${hostActivity.packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("📱 收到手机快传 APK 安装包")
|
||||
.setMessage("应用文件:$filename\n大小:${file.length() / 1024 / 1024}MB\n是否立即安装?")
|
||||
.setPositiveButton("立即安装") { _, _ ->
|
||||
val senderInfo = if (clientIp.isNotBlank()) "来源设备:$clientIp\n" else ""
|
||||
AlertDialog.Builder(hostActivity)
|
||||
.setTitle("📱 收到局域网快传 APK 安装包")
|
||||
.setMessage("${senderInfo}应用文件:$filename\n文件大小:${file.length() / 1024 / 1024}MB\n\n⚠️ 请确认该安装包为本人操作发送后再继续安装。")
|
||||
.setPositiveButton("确认并安装") { _, _ ->
|
||||
try {
|
||||
activity.startActivity(intent)
|
||||
hostActivity.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
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()
|
||||
} catch (e: Exception) {
|
||||
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.File
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import kotlin.jvm.Volatile
|
||||
|
||||
private const val TAG = "RemoteControlServer"
|
||||
@@ -16,6 +17,11 @@ private const val PORT = 8766
|
||||
|
||||
/**
|
||||
* 本地 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(
|
||||
private val context: Context,
|
||||
@@ -26,6 +32,9 @@ class RemoteControlServer(
|
||||
@Volatile
|
||||
private var running = false
|
||||
|
||||
/** 每次服务实例生成独立的随机同源会话令牌 */
|
||||
private val sessionToken: String = UUID.randomUUID().toString().replace("-", "")
|
||||
|
||||
/** 服务是否仍在运行(用于状态检测;与 NanoHTTPD.isAlive 区分,避免 override final) */
|
||||
fun isServerRunning(): Boolean = running
|
||||
|
||||
@@ -45,6 +54,7 @@ class RemoteControlServer(
|
||||
private val htmlPage: String by lazy {
|
||||
try {
|
||||
context.assets.open("remote.html").bufferedReader().use { it.readText() }
|
||||
.replace("__TV_SESSION_TOKEN__", sessionToken)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to load remote.html", e)
|
||||
"<html><body>Failed to load remote page.</body></html>"
|
||||
@@ -53,43 +63,44 @@ class RemoteControlServer(
|
||||
|
||||
val port: Int get() = PORT
|
||||
|
||||
private fun newCorsResponse(
|
||||
private fun newSameOriginResponse(
|
||||
status: NanoHTTPD.Response.IStatus,
|
||||
mimeType: String,
|
||||
data: ByteArray
|
||||
): NanoHTTPD.Response {
|
||||
return newFixedLengthResponse(status, mimeType, ByteArrayInputStream(data), data.size.toLong()).apply {
|
||||
addHeader("Access-Control-Allow-Origin", "*")
|
||||
addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
addHeader("Access-Control-Max-Age", "86400")
|
||||
addHeader("X-Content-Type-Options", "nosniff")
|
||||
addHeader("Cache-Control", "no-store")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
val uri = session.uri
|
||||
if (session.method == NanoHTTPD.Method.OPTIONS) {
|
||||
return newCorsResponse(
|
||||
NanoHTTPD.Response.Status.OK,
|
||||
"text/plain",
|
||||
ByteArray(0)
|
||||
)
|
||||
}
|
||||
if (session.method == NanoHTTPD.Method.GET && (uri == "/" || uri == "/remote" || uri.isEmpty())) {
|
||||
val htmlBytes = htmlPage.toByteArray(Charsets.UTF_8)
|
||||
return newCorsResponse(
|
||||
return newSameOriginResponse(
|
||||
NanoHTTPD.Response.Status.OK,
|
||||
"text/html; charset=utf-8",
|
||||
htmlBytes
|
||||
)
|
||||
}
|
||||
if (session.method == NanoHTTPD.Method.POST && uri == "/cmd") {
|
||||
return handleCommand(session)
|
||||
if (session.method == NanoHTTPD.Method.POST && (uri == "/cmd" || uri == "/upload")) {
|
||||
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 handleUpload(session)
|
||||
}
|
||||
return newCorsResponse(
|
||||
return newSameOriginResponse(
|
||||
NanoHTTPD.Response.Status.NOT_FOUND,
|
||||
"text/plain",
|
||||
"Not Found".toByteArray(Charsets.UTF_8)
|
||||
@@ -109,7 +120,7 @@ class RemoteControlServer(
|
||||
if (body.isNullOrBlank()) {
|
||||
try {
|
||||
val contentLength = session.headers["content-length"]?.toIntOrNull() ?: 0
|
||||
if (contentLength > 0 && contentLength < 4096) {
|
||||
if (contentLength in 1..4095) {
|
||||
val buf = ByteArray(contentLength)
|
||||
var offset = 0
|
||||
val stream = session.inputStream
|
||||
@@ -152,7 +163,7 @@ class RemoteControlServer(
|
||||
Log.e(TAG, "Parse cmd failed: $body", e)
|
||||
}
|
||||
}
|
||||
return newCorsResponse(
|
||||
return newSameOriginResponse(
|
||||
NanoHTTPD.Response.Status.OK,
|
||||
"application/json",
|
||||
"{}".toByteArray(Charsets.UTF_8)
|
||||
@@ -172,18 +183,25 @@ class RemoteControlServer(
|
||||
rawFilename = "upload_${System.currentTimeMillis()}"
|
||||
}
|
||||
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()
|
||||
if (tempFilePath != null) {
|
||||
val tempFile = File(tempFilePath)
|
||||
if (tempFile.exists() && tempFile.length() > 0) {
|
||||
val uploadDir = File(context.getExternalFilesDir("uploads") ?: context.filesDir, "uploads")
|
||||
uploadDir.mkdirs()
|
||||
val uploadDir = getUploadDir(context)
|
||||
val targetFile = File(uploadDir, cleanFilename)
|
||||
tempFile.copyTo(targetFile, overwrite = true)
|
||||
val clientIp = session.remoteIpAddress.orEmpty()
|
||||
|
||||
mainHandler.post {
|
||||
callback?.onRemoteFileUploaded(targetFile, cleanFilename)
|
||||
callback?.onRemoteFileUploaded(targetFile, cleanFilename, clientIp)
|
||||
}
|
||||
|
||||
val respJson = JSONObject().apply {
|
||||
@@ -191,7 +209,7 @@ class RemoteControlServer(
|
||||
put("filename", cleanFilename)
|
||||
put("size", targetFile.length())
|
||||
}
|
||||
return newCorsResponse(
|
||||
return newSameOriginResponse(
|
||||
NanoHTTPD.Response.Status.OK,
|
||||
"application/json",
|
||||
respJson.toString().toByteArray(Charsets.UTF_8)
|
||||
@@ -204,17 +222,41 @@ class RemoteControlServer(
|
||||
put("status", "error")
|
||||
put("message", e.message ?: "unknown")
|
||||
}
|
||||
return newCorsResponse(
|
||||
return newSameOriginResponse(
|
||||
NanoHTTPD.Response.Status.INTERNAL_ERROR,
|
||||
"application/json",
|
||||
errJson.toString().toByteArray(Charsets.UTF_8)
|
||||
)
|
||||
}
|
||||
|
||||
return newCorsResponse(
|
||||
return newSameOriginResponse(
|
||||
NanoHTTPD.Response.Status.BAD_REQUEST,
|
||||
"application/json",
|
||||
"{\"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.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 {
|
||||
act?.showRemoteControlDialog()
|
||||
|
||||
@@ -4,20 +4,20 @@ import android.app.Application
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.FileWriter
|
||||
import java.io.PrintWriter
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* 应用入口。安装全局未捕获异常处理器,在崩溃时将日志写入文件。
|
||||
* 应用入口。安装全局未捕获异常处理器,在崩溃时将日志写入文件,并定期裁剪保留最近 [MAX_CRASH_LOGS] 条。
|
||||
*/
|
||||
class TVBrowserApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
installCrashHandler()
|
||||
pruneCrashLogsAsync()
|
||||
}
|
||||
|
||||
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) {
|
||||
val dir = File(filesDir, CRASH_LOG_DIR)
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
@@ -60,6 +78,7 @@ class TVBrowserApplication : Application() {
|
||||
}
|
||||
pw.flush()
|
||||
}
|
||||
runCatching { pruneCrashLogs(dir) }
|
||||
Log.w(TAG, "崩溃日志已保存: ${file.absolutePath}")
|
||||
}
|
||||
|
||||
@@ -67,5 +86,6 @@ class TVBrowserApplication : Application() {
|
||||
private const val TAG = "TVBrowser.Crash"
|
||||
/** 崩溃日志所在子目录,可通过 Context.filesDir/CRASH_LOG_DIR 访问 */
|
||||
const val CRASH_LOG_DIR = "crash_logs"
|
||||
private const val MAX_CRASH_LOGS = 20
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,18 @@
|
||||
package com.tv.browser
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* 用户脚本持久化:SharedPreferences + JSON。
|
||||
* 用户脚本持久化:基于 [JsonListStore] 内存缓存 + SharedPreferences,
|
||||
* 避免每次页面加载完成(onPageFinished)都在主线程重复解析 JSON。
|
||||
*/
|
||||
class UserScriptRepository(context: Context) {
|
||||
|
||||
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
private val keyScripts = "user_scripts"
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
fun getAll(): List<UserScript> {
|
||||
val json = prefs.getString(keyScripts, null)
|
||||
if (json.isNullOrBlank()) return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(json)
|
||||
List(arr.length()) { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
UserScript(
|
||||
id = obj.optString(KEY_ID, UUID.randomUUID().toString()),
|
||||
name = obj.optString(KEY_NAME, ""),
|
||||
urlPattern = obj.optString(KEY_URL_PATTERN, "*"),
|
||||
script = obj.optString(KEY_SCRIPT, ""),
|
||||
enabled = obj.optBoolean(KEY_ENABLED, true)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
fun getAll(): List<UserScript> = store.getAll(appContext)
|
||||
|
||||
fun getEnabledForUrl(url: String): List<UserScript> =
|
||||
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() }
|
||||
list.removeAll { it.id == newId }
|
||||
list.add(0, script.copy(id = newId))
|
||||
save(list)
|
||||
store.save(appContext, list)
|
||||
}
|
||||
|
||||
fun update(script: UserScript) {
|
||||
@@ -50,7 +30,7 @@ class UserScriptRepository(context: Context) {
|
||||
val idx = list.indexOfFirst { it.id == script.id }
|
||||
if (idx >= 0) {
|
||||
list[idx] = script
|
||||
save(list)
|
||||
store.save(appContext, list)
|
||||
} else {
|
||||
add(script)
|
||||
}
|
||||
@@ -61,34 +41,44 @@ class UserScriptRepository(context: Context) {
|
||||
val idx = list.indexOfFirst { it.id == id }
|
||||
if (idx >= 0) {
|
||||
list[idx] = list[idx].copy(enabled = enabled)
|
||||
save(list)
|
||||
store.save(appContext, list)
|
||||
}
|
||||
}
|
||||
|
||||
fun remove(id: String) {
|
||||
save(getAll().filter { it.id != id })
|
||||
}
|
||||
|
||||
private fun save(list: List<UserScript>) {
|
||||
val arr = JSONArray()
|
||||
list.forEach { s ->
|
||||
arr.put(JSONObject().apply {
|
||||
put(KEY_ID, s.id)
|
||||
put(KEY_NAME, s.name)
|
||||
put(KEY_URL_PATTERN, s.urlPattern)
|
||||
put(KEY_SCRIPT, s.script)
|
||||
put(KEY_ENABLED, s.enabled)
|
||||
})
|
||||
}
|
||||
prefs.edit().putString(keyScripts, arr.toString()).apply()
|
||||
store.save(appContext, getAll().filter { it.id != id })
|
||||
}
|
||||
|
||||
companion object {
|
||||
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_NAME = "name"
|
||||
private const val KEY_URL_PATTERN = "urlPattern"
|
||||
private const val KEY_SCRIPT = "script"
|
||||
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" */
|
||||
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'; } })();"
|
||||
@@ -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'; } })();"
|
||||
.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 视频全屏被拦截的问题
|
||||
|
||||
@@ -23,6 +23,18 @@ object VideoSnifferHelper {
|
||||
|
||||
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 是否为多媒体视频流地址
|
||||
*/
|
||||
@@ -39,11 +51,7 @@ object VideoSnifferHelper {
|
||||
}
|
||||
|
||||
// 解析 Path
|
||||
val path = try {
|
||||
Uri.parse(url).path?.lowercase() ?: ""
|
||||
} catch (_: Exception) {
|
||||
lower
|
||||
}
|
||||
val path = extractPath(url)?.lowercase() ?: lower
|
||||
|
||||
for (ext in VIDEO_EXTENSIONS) {
|
||||
if (path.endsWith(ext) || lower.contains("$ext?") || lower.contains("$ext#") || lower.endsWith(ext)) {
|
||||
@@ -66,11 +74,7 @@ object VideoSnifferHelper {
|
||||
* 后台解析 WebView 已开启 blockNetworkImage,能走到这里的图片后缀请求基本是 XHR 加载的伪装分片。
|
||||
*/
|
||||
fun isMediaSegmentUrl(url: String): Boolean {
|
||||
val path = try {
|
||||
Uri.parse(url).path?.lowercase() ?: return false
|
||||
} catch (_: Exception) {
|
||||
return false
|
||||
}
|
||||
val path = extractPath(url)?.lowercase() ?: return false
|
||||
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/...)。
|
||||
*/
|
||||
fun isWebOnlyStream(url: String): Boolean {
|
||||
val uri = try { Uri.parse(url.trim()) } catch (_: Exception) { return false }
|
||||
val host = uri.host?.lowercase() ?: return false
|
||||
val host = extractHost(url)?.lowercase() ?: 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 {
|
||||
if (newUrl == existingUrl) return true
|
||||
if (!isHls(newUrl) || !isHls(existingUrl)) return false
|
||||
return try {
|
||||
val a = Uri.parse(newUrl)
|
||||
val b = Uri.parse(existingUrl)
|
||||
if (a.host != b.host) return false
|
||||
val dirA = a.path?.substringBeforeLast('/') ?: return false
|
||||
val dirB = b.path?.substringBeforeLast('/') ?: return false
|
||||
dirA.startsWith(dirB)
|
||||
} catch (_: Exception) {
|
||||
false
|
||||
}
|
||||
val hostA = extractHost(newUrl) ?: return false
|
||||
val hostB = extractHost(existingUrl) ?: return false
|
||||
if (hostA != hostB) return false
|
||||
val dirA = extractPath(newUrl)?.substringBeforeLast('/') ?: return false
|
||||
val dirB = extractPath(existingUrl)?.substringBeforeLast('/') ?: return false
|
||||
return dirA.startsWith(dirB)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
@@ -1041,12 +1040,21 @@ object VideoSnifferHelper {
|
||||
}
|
||||
}
|
||||
|
||||
window.__tvFindVideosNow = function() { findVideos(document); };
|
||||
findVideos(document);
|
||||
|
||||
// 监听后续动态添加或加载的视频
|
||||
if (window.__tvDomSnifferInstalled) return;
|
||||
window.__tvDomSnifferInstalled = true;
|
||||
|
||||
// 节流监听后续动态添加或加载的视频,避免高频 DOM 变动卡顿
|
||||
var sniffTimer = 0;
|
||||
try {
|
||||
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 });
|
||||
} catch(e) {}
|
||||
} catch(e) {}
|
||||
|
||||
@@ -38,23 +38,6 @@ data class Episode(val name: String, val url: String)
|
||||
/** 播放线路(如「NB源」「量子资源」),每条线路有自己的选集列表 */
|
||||
data class PlaySource(val name: String, val episodes: List<Episode>) {
|
||||
companion object {
|
||||
/** 线路列表与 JSON 互转(用于 Intent 传递) */
|
||||
fun listToJson(list: List<PlaySource>): String {
|
||||
val arr = JSONArray()
|
||||
list.forEach { s ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("name", s.name)
|
||||
put("episodes", episodesToJson(s.episodes))
|
||||
})
|
||||
}
|
||||
return arr.toString()
|
||||
}
|
||||
|
||||
fun listFromJson(json: String?): List<PlaySource> {
|
||||
if (json.isNullOrBlank()) return emptyList()
|
||||
return try { parseArray(JSONArray(json)) } catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
internal fun parseArray(arr: JSONArray?): List<PlaySource> {
|
||||
if (arr == null) return emptyList()
|
||||
val out = mutableListOf<PlaySource>()
|
||||
@@ -76,12 +59,6 @@ data class PlaySource(val name: String, val episodes: List<Episode>) {
|
||||
}
|
||||
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 加载仅含 <video src="url"> 的简单页,用于直链或 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("&", "&")
|
||||
.replace("\"", """)
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
|
||||
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.URL
|
||||
import java.net.URLEncoder
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import javax.net.ssl.HostnameVerifier
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/** 一条聚合搜索结果 */
|
||||
data class VodResult(
|
||||
@@ -242,12 +235,10 @@ class VodSearchHelper(context: Context) {
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
if (conn is HttpsURLConnection) {
|
||||
conn.sslSocketFactory = lenientSslSocketFactory
|
||||
conn.hostnameVerifier = lenientHostnameVerifier
|
||||
}
|
||||
PlayerSsl.applyTo(conn, lenient = useLenient)
|
||||
conn.connectTimeout = TIMEOUT_MS
|
||||
conn.readTimeout = TIMEOUT_MS
|
||||
conn.instanceFollowRedirects = true
|
||||
@@ -256,7 +247,9 @@ class VodSearchHelper(context: Context) {
|
||||
if (ajax) conn.setRequestProperty("X-Requested-With", "XMLHttpRequest")
|
||||
conn.setRequestProperty("Accept", if (ajax) "application/json, text/javascript, */*" else "text/html,*/*")
|
||||
if (!cookie.isNullOrBlank()) conn.setRequestProperty("Cookie", cookie)
|
||||
if (conn.responseCode !in 200..299) return null
|
||||
val code = conn.responseCode
|
||||
NetworkChecker.updateServerDate(conn.date)
|
||||
if (code !in 200..299) return null
|
||||
val bytes = conn.inputStream.use { input ->
|
||||
val buf = java.io.ByteArrayOutputStream()
|
||||
val chunk = ByteArray(16 * 1024)
|
||||
@@ -302,22 +295,11 @@ class VodSearchHelper(context: Context) {
|
||||
lastSearchResults = results
|
||||
}
|
||||
|
||||
private val lenientHostnameVerifier = HostnameVerifier { _, _ -> true }
|
||||
private val sharedExecutor: ExecutorService by lazy {
|
||||
Executors.newFixedThreadPool(THREADS) { r ->
|
||||
Thread(r, "VodSearchWorker").apply { isDaemon = true }
|
||||
}
|
||||
}
|
||||
private val lenientSslSocketFactory by lazy {
|
||||
val trustAll = arrayOf<TrustManager>(
|
||||
object : X509TrustManager {
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||
override fun checkClientTrusted(chain: Array<X509Certificate>?, authType: String?) {}
|
||||
override fun checkServerTrusted(chain: Array<X509Certificate>?, authType: String?) {}
|
||||
}
|
||||
)
|
||||
SSLContext.getInstance("TLS").apply { init(null, trustAll, SecureRandom()) }.socketFactory
|
||||
}
|
||||
|
||||
/** 常见的苹果 CMS 详情页伪静态规则(最后一个是动态路由,伪静态未开时也能用) */
|
||||
private val COMMON_TEMPLATES = listOf(
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/focus_highlight" />
|
||||
<solid android:color="@color/accent_sky" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@color/url_bar_bg" />
|
||||
<solid android:color="@color/card_bg" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -1,13 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- TV 桌面图标/横幅背景色,修改此值即可更换桌面上的图标背景(勿用纯白 #FFFFFFFF 易与桌面融为一体) -->
|
||||
<!-- TV 桌面图标/横幅背景色,修改此值即可更换桌面上的图标背景 -->
|
||||
<color name="tv_banner_background">#FFFFFFFF</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="primary">#FF6200EE</color>
|
||||
<color name="primary_dark">#FF3700B3</color>
|
||||
<color name="accent">#FF03DAC5</color>
|
||||
<color name="primary">#FF38BDF8</color>
|
||||
<color name="primary_dark">#FF0284C7</color>
|
||||
<color name="accent">#FF38BDF8</color>
|
||||
<color name="toolbar_bg">#FF1E1E1E</color>
|
||||
<color name="url_bar_bg">#FF2D2D2D</color>
|
||||
<color name="focus_highlight">#FF03DAC5</color>
|
||||
<!-- 统一高频 UI 色彩(背景、卡片、主/次文字、强调色) -->
|
||||
<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>
|
||||
|
||||
@@ -4,21 +4,15 @@
|
||||
<string name="cursor_pointer_desc">模拟鼠标指针</string>
|
||||
<string name="url_hint">输入网址或搜索</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_done">完成整理</string>
|
||||
<string name="refresh">刷新</string>
|
||||
<string name="home">首页</string>
|
||||
<string name="default_home_url">https://www.ppnix.com/cn/</string>
|
||||
<string name="loading">加载中…</string>
|
||||
<string name="error_page">无法加载此页</string>
|
||||
<string name="bookmark">收藏</string>
|
||||
<string name="bookmarks">收藏夹</string>
|
||||
<string name="bookmark_added">已加入收藏</string>
|
||||
<string name="bookmark_removed">已移除收藏</string>
|
||||
<string name="no_bookmarks">暂无收藏,浏览时点击「收藏」可添加</string>
|
||||
<string name="remove">移除</string>
|
||||
<string name="close">关闭</string>
|
||||
<string name="remote_control_title">触屏遥控</string>
|
||||
@@ -42,26 +36,8 @@
|
||||
<string name="network_checking">正在检测网络…</string>
|
||||
<string name="internet_ok">互联网:正常</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_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_url_not_available">无法获取播放地址</string>
|
||||
<string name="video_blob_not_supported">该视频为流式地址,无法在新页面播放</string>
|
||||
<string name="user_scripts">用户脚本</string>
|
||||
<string name="user_scripts_hint">油猴式:在匹配的页面加载后注入自定义 JS</string>
|
||||
<string name="user_script_add">添加脚本</string>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-files-path name="uploads" path="uploads" />
|
||||
<files-path name="internal_uploads" path="uploads" />
|
||||
<cache-path name="cache" path="." />
|
||||
</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
Binary file not shown.
@@ -1,5 +1,7 @@
|
||||
# Project-wide Gradle settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
@@ -3,9 +3,6 @@ agp = "8.2.2"
|
||||
kotlin = "1.9.22"
|
||||
coreKtx = "1.12.0"
|
||||
appcompat = "1.6.1"
|
||||
material = "1.11.0"
|
||||
constraintlayout = "2.1.4"
|
||||
leanback = "1.2.0-alpha04"
|
||||
fragment = "1.6.2"
|
||||
recyclerview = "1.3.2"
|
||||
nanohttpd = "2.3.1"
|
||||
@@ -13,27 +10,19 @@ zxing = "3.5.2"
|
||||
media3 = "1.3.1"
|
||||
webkit = "1.10.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.1.5"
|
||||
espressoCore = "3.5.1"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
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" }
|
||||
recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
|
||||
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" }
|
||||
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-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
|
||||
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
||||
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]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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` 按面板拆分;高频颜色资源化。
|
||||
Reference in New Issue
Block a user