This commit is contained in:
haomingming
2026-11-09 15:49:32 +08:00
parent 227a39552d
commit 76ec185e76
49 changed files with 1412 additions and 1094 deletions
+2 -4
View File
@@ -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)
}
+2 -8
View File
@@ -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"
+16 -5
View File
@@ -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
}
+54 -19
View File
@@ -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 加载仅含 &lt;video src="url"&gt; 的简单页,用于直链或 contentUrl。
*/
class VideoWebActivity : AppCompatActivity() {
private lateinit var binding: ActivityVideoWebBinding
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityVideoWebBinding.inflate(layoutInflater)
setContentView(binding.root)
val url = intent.getStringExtra(EXTRA_VIDEO_URL) ?: ""
if (url.isBlank()) {
finish()
return
}
binding.webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
useWideViewPort = true
loadWithOverviewMode = true
}
binding.webView.webViewClient = object : android.webkit.WebViewClient() {
override fun onReceivedSslError(
view: WebView?,
handler: android.webkit.SslErrorHandler?,
error: android.net.http.SslError?
) {
handler?.proceed()
}
}
val html = """
<!DOCTYPE html>
<html><head><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;background:#000;">
<video src="${escapeHtmlAttr(url)}" controls autoplay playsinline style="width:100%;height:100%;object-fit:contain;"></video>
</body></html>
""".trimIndent()
binding.webView.loadDataWithBaseURL(
intent.getStringExtra(EXTRA_BASE_URL).orEmpty(),
html,
"text/html",
"UTF-8",
null
)
}
override fun onResume() {
super.onResume()
if (::binding.isInitialized) {
binding.webView.onResume()
}
}
override fun onPause() {
if (::binding.isInitialized) {
try {
binding.webView.evaluateJavascript(VideoSnifferHelper.JS_PAUSE_ALL_MEDIA, null)
binding.webView.onPause()
} catch (_: Exception) { }
}
super.onPause()
}
override fun onStop() {
if (::binding.isInitialized) {
try {
binding.webView.evaluateJavascript(VideoSnifferHelper.JS_PAUSE_ALL_MEDIA, null)
binding.webView.onPause()
} catch (_: Exception) { }
}
super.onStop()
}
override fun onDestroy() {
if (::binding.isInitialized) {
try {
binding.webView.stopLoading()
(binding.webView.parent as? ViewGroup)?.removeView(binding.webView)
binding.webView.destroy()
} catch (_: Exception) { }
}
super.onDestroy()
}
private fun escapeHtmlAttr(s: String): String {
return s.replace("&", "&amp;")
.replace("\"", "&quot;")
.replace("<", "&lt;")
.replace(">", "&gt;")
}
companion object {
const val EXTRA_VIDEO_URL = "video_url"
const val EXTRA_BASE_URL = "base_url"
}
}
@@ -10,15 +10,8 @@ import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.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 -2
View File
@@ -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>
-55
View File
@@ -1,55 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/bookmarkItemRoot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:background="@drawable/btn_focus_bg"
android:focusable="true"
android:focusableInTouchMode="true"
android:clickable="true"
android:minHeight="56dp">
<LinearLayout
android:id="@+id/bookmarkContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:focusable="false"
android:clickable="true">
<TextView
android:id="@+id/bookmarkTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@color/white"
android:textSize="16sp"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/bookmarkUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#99FFFFFF"
android:textSize="12sp"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>
<Button
android:id="@+id/btnRemove"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginStart="12dp"
android:text="@string/remove"
android:minWidth="64dp" />
</LinearLayout>
@@ -1,82 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/videoControlBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:background="#E6000000"
android:visibility="gone"
android:focusable="false">
<Button
android:id="@+id/videoBtnPlayPause"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:text="@string/video_play"
android:minWidth="72dp" />
<Button
android:id="@+id/videoBtnRewind"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:text="@string/video_rewind"
android:minWidth="72dp" />
<Button
android:id="@+id/videoBtnForward"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:text="@string/video_forward"
android:minWidth="72dp" />
<Button
android:id="@+id/videoBtnVolumeDown"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:text="@string/video_volume_down"
android:minWidth="56dp" />
<Button
android:id="@+id/videoBtnVolumeUp"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:text="@string/video_volume_up"
android:minWidth="56dp" />
<Button
android:id="@+id/videoBtnFullscreen"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:text="@string/video_fullscreen"
android:minWidth="72dp" />
<View
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<Button
android:id="@+id/videoBtnClose"
style="@style/Widget.TVBrowser.ToolbarButton"
android:layout_width="wrap_content"
android:layout_height="44dp"
android:text="@string/video_close"
android:minWidth="72dp" />
</LinearLayout>
+11 -6
View File
@@ -1,13 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<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>
-24
View File
@@ -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
View File
@@ -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(""))
}
}