init
@@ -0,0 +1,7 @@
|
|||||||
|
/build/
|
||||||
|
.idea/
|
||||||
|
.gradle/*
|
||||||
|
*/release
|
||||||
|
*/debug
|
||||||
|
*/build/
|
||||||
|
local.properties
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# TV 浏览器
|
||||||
|
|
||||||
|
在 Android TV 上使用的简易网页浏览器应用,基于 WebView,支持遥控器 D-pad 导航。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **网页浏览**:使用系统 WebView 加载网页,支持 JavaScript。
|
||||||
|
- **工具栏**:后退、前进、刷新、首页、地址栏、前往,均支持遥控器焦点。
|
||||||
|
- **遥控器操作**:
|
||||||
|
- D-pad 在工具栏按钮和地址栏、WebView 之间切换焦点。
|
||||||
|
- 中心键/确认键在地址栏按下可执行“前往”。
|
||||||
|
- 返回键在可后退时先后退网页,否则退出应用。
|
||||||
|
- **从外部打开链接**:支持作为浏览器处理 `http`/`https` 链接(如从其他应用“在浏览器中打开”)。
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Android Studio Ladybug (2024.2.1) 或更高版本(推荐)
|
||||||
|
- JDK 17
|
||||||
|
- 目标设备:Android TV(Android 5.0 / API 21+,且支持 Leanback)
|
||||||
|
|
||||||
|
## 构建与运行
|
||||||
|
|
||||||
|
1. 用 **Android Studio** 打开本项目根目录(`tv` 文件夹)。
|
||||||
|
2. 等待 Gradle 同步完成。
|
||||||
|
3. 连接 Android TV 设备或启动 TV 模拟器:
|
||||||
|
- 设备:开启开发者选项与 USB 调试,用 USB 连接电脑。
|
||||||
|
- 模拟器:**Device Manager** → **Create Device** → 选择 TV 设备(如 “TV (1080p)”)→ 选择系统镜像并启动。
|
||||||
|
4. 点击 **Run**(绿色三角)或使用 `Shift+F10`,选择 TV 设备/模拟器,安装并运行应用。
|
||||||
|
|
||||||
|
在 TV 主屏的“应用”列表中可看到 **TV 浏览器**,进入后即可输入网址或使用默认首页进行浏览。
|
||||||
|
|
||||||
|
## 项目结构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
tv/
|
||||||
|
├── app/
|
||||||
|
│ ├── src/main/
|
||||||
|
│ │ ├── AndroidManifest.xml # TV 与浏览器声明、权限
|
||||||
|
│ │ ├── java/com/tv/browser/
|
||||||
|
│ │ │ └── BrowserActivity.kt # 主界面与 WebView 逻辑
|
||||||
|
│ │ └── res/
|
||||||
|
│ │ ├── layout/activity_browser.xml
|
||||||
|
│ │ ├── values/strings.xml, colors.xml, themes.xml
|
||||||
|
│ │ └── drawable/ # 图标、焦点背景等
|
||||||
|
│ ├── build.gradle.kts
|
||||||
|
│ └── proguard-rules.pro
|
||||||
|
├── gradle/
|
||||||
|
│ ├── libs.versions.toml
|
||||||
|
│ └── wrapper/gradle-wrapper.properties
|
||||||
|
├── build.gradle.kts
|
||||||
|
├── settings.gradle.kts
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 自定义
|
||||||
|
|
||||||
|
- **默认首页**:在 `app/src/main/res/values/strings.xml` 中修改 `default_home_url`(当前为 `https://www.google.com`),可改为任意合法 URL。
|
||||||
|
- **主题与颜色**:在 `res/values/colors.xml` 和 `themes.xml` 中调整。
|
||||||
|
- **TV 主屏图标**:替换 `res/drawable/banner.xml` 或使用 320×180 的横条图(banner)资源。
|
||||||
|
|
||||||
|
## 许可
|
||||||
|
|
||||||
|
本项目仅供学习与参考使用。浏览器内核为系统 WebView,实际渲染与安全策略取决于设备系统版本。
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
启动模拟器
|
||||||
|
|
||||||
|
C:\Users\wucongxing\AppData\Local\Android\Sdk\emulator\emulator -avd Television_720p -dns-server 8.8.8.8,8.8.4.4
|
||||||
|
|
||||||
|
C:\Users\wucongxing\AppData\Local\Android\Sdk\platform-tools\adb forward tcp:8767 tcp:8766
|
||||||
|
---
|
||||||
|
|
||||||
|
## 手机遥控 · 模拟器调试(电脑访问 127.0.0.1:8767 打不开时排查)
|
||||||
|
|
||||||
|
电脑浏览器访问 `http://127.0.0.1:8767` 需要先做 **adb 端口转发**(`adb forward`;Windows 上 `reverse` 可能不监听,用 `forward`),并按下面顺序排查。
|
||||||
|
|
||||||
|
### 1. 确认设备已连接
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb devices
|
||||||
|
```
|
||||||
|
|
||||||
|
应看到至少一行设备(如 `emulator-5554 device`)。若为 `offline` 或 `unauthorized`,先处理连接/授权。
|
||||||
|
|
||||||
|
### 2. 多设备时指定当前 TV 设备
|
||||||
|
|
||||||
|
若列表里有多台设备,必须指定要调试的那台:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb -s emulator-5554 forward tcp:8767 tcp:8766
|
||||||
|
```
|
||||||
|
|
||||||
|
把 `emulator-5554` 换成你 `adb devices` 里那台 TV 模拟器的序列号。
|
||||||
|
|
||||||
|
### 3. 使用 adb forward(Windows 上 reverse 可能不监听,用 forward)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb forward --remove tcp:8767
|
||||||
|
adb forward tcp:8767 tcp:8766
|
||||||
|
```
|
||||||
|
|
||||||
|
多设备时加 `-s <设备号>`,例如:`adb -s emulator-5554 forward tcp:8767 tcp:8766`。
|
||||||
|
|
||||||
|
### 4. 确认转发已生效
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb forward --list
|
||||||
|
```
|
||||||
|
|
||||||
|
应出现一行:`emulator-5554 tcp:8767 tcp:8766`(或你的设备号)。
|
||||||
|
|
||||||
|
### 5. 先在模拟器里启动遥控服务(必须)
|
||||||
|
|
||||||
|
- 在 **TV 模拟器**里打开 **TV 浏览器**,点击工具栏 **「手机遥控」**(弹窗会打开,服务启动;关掉弹窗后服务也会常驻)。
|
||||||
|
- 在模拟器里点弹窗的 **「在电视上打开」**,应能打开遥控页;若 TV 里能打开,说明本机 8766 服务正常。
|
||||||
|
- **未在模拟器里点过「手机遥控」时,电脑访问 8767 会连不上。**
|
||||||
|
|
||||||
|
### 6. 在电脑上再试 8767
|
||||||
|
|
||||||
|
确认上一步 TV 里能打开 127.0.0.1:8766 后,在电脑执行:`Invoke-WebRequest -Uri http://127.0.0.1:8767/ -UseBasicParsing` 或浏览器访问 `http://127.0.0.1:8767`。
|
||||||
|
|
||||||
|
### 7. 电脑仍连不上 8767 时
|
||||||
|
|
||||||
|
按顺序做:
|
||||||
|
|
||||||
|
1. **确认本机 8767 有进程在听**(PowerShell 或 CMD):
|
||||||
|
```powershell
|
||||||
|
netstat -ano | findstr :8767
|
||||||
|
```
|
||||||
|
应有至少一行 `LISTENING`(多为 adb)。若**没有任何输出**,说明 adb 未在本机监听 8767,可尝试:
|
||||||
|
- 用设备号再绑一次:`adb -s emulator-5554 forward --remove tcp:8767`,再 `adb -s emulator-5554 forward tcp:8767 tcp:8766`
|
||||||
|
- 重启 adb:`adb kill-server`,再 `adb start-server`,再执行上述 forward
|
||||||
|
- 用**管理员身份**打开 PowerShell/CMD 再执行 `adb forward tcp:8767 tcp:8766`
|
||||||
|
|
||||||
|
2. **确认模拟器里服务已启动**:在 TV 模拟器里打开 TV 浏览器 → 点一次 **「手机遥控」**(可再关弹窗)→ 在 TV 里点「在电视上打开」能打开 127.0.0.1:8766 的页面。若 TV 里都打不开,先解决模拟器内 8766 再试电脑 8767。
|
||||||
|
|
||||||
|
3. **本机防火墙**:临时关 Windows 防火墙或为“专用/本地”网络放行,再试 `Invoke-WebRequest -Uri http://127.0.0.1:8767/ -UseBasicParsing`。
|
||||||
|
|
||||||
|
4. **换用 curl 试**(若已安装):`curl -v http://127.0.0.1:8767/`
|
||||||
|
|
||||||
|
### 8. 服务常驻说明
|
||||||
|
|
||||||
|
设备上的 8766 服务在**点过一次「手机遥控」后常驻**,关弹窗不关服务。请务必先完成「5. 先在模拟器里启动遥控服务」再在电脑访问 8767。
|
||||||
|
|
||||||
|
### 9. 在电脑上确认 8767 是否通
|
||||||
|
|
||||||
|
在 **PowerShell** 或 **CMD** 里执行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
curl -v http://127.0.0.1:8767/
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
(Invoke-WebRequest -Uri http://127.0.0.1:8767/ -UseBasicParsing -TimeoutSec 5).StatusCode
|
||||||
|
```
|
||||||
|
|
||||||
|
- 若返回 **200** 或能看到 HTML:说明端口通,再试浏览器(可换无痕/别的浏览器)。
|
||||||
|
- 若 **连接被拒绝 / Connection refused**:多半是 TV 里弹窗没开或服务未启动,请先在 TV 里打开「手机遥控」弹窗再执行上述命令。
|
||||||
|
- 若 **超时**:检查本机防火墙或杀毒是否拦截本地 8767。
|
||||||
|
|
||||||
|
### 10. 一键执行(单设备时)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
adb forward --remove tcp:8767
|
||||||
|
adb forward tcp:8767 tcp:8766
|
||||||
|
adb forward --list
|
||||||
|
```
|
||||||
|
|
||||||
|
看到 `tcp:8767 tcp:8766` 后,**在 TV 应用里点一次「手机遥控」启动服务**,再在电脑访问 `http://127.0.0.1:8767`。
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.tv.browser"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.tv.browser"
|
||||||
|
minSdk = 21
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 11
|
||||||
|
versionName = "4.5.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding = true
|
||||||
|
// 生成 BuildConfig.VERSION_NAME,版本号只需在上面 defaultConfig 改一处
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
implementation(libs.media3.exoplayer.hls)
|
||||||
|
implementation(libs.media3.ui)
|
||||||
|
// 页面执行前注入脚本(可进入所有 iframe)
|
||||||
|
implementation(libs.androidx.webkit)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
# Keep WebView related classes
|
||||||
|
-keepclassmembers class * extends android.webkit.WebViewClient {
|
||||||
|
public void *(android.webkit.WebView, java.lang.String);
|
||||||
|
public boolean *(android.webkit.WebView, java.lang.String);
|
||||||
|
}
|
||||||
|
-keepclassmembers class * extends android.webkit.WebChromeClient {
|
||||||
|
public void *(android.webkit.WebView, java.lang.String);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- 网络访问(浏览网页必需) -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
|
<!-- 声明为 Android TV 应用,且不要求触屏 -->
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.touchscreen"
|
||||||
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.software.leanback"
|
||||||
|
android:required="true" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".TVBrowserApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:banner="@drawable/banner"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.TVBrowser"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
tools:targetApi="34">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".BrowserActivity"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.TVBrowser.NoActionBar">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="http" />
|
||||||
|
<data android:scheme="https" />
|
||||||
|
</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"
|
||||||
|
android:exported="false"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:supportsPictureInPicture="true"
|
||||||
|
android:theme="@style/Theme.TVBrowser.NoActionBar" />
|
||||||
|
|
||||||
|
<!-- 局域网快传 APK 自动安装 FileProvider -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<title>小北浏览器 · 触屏遥控</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: #12141a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 14px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, #38bdf8 0%, #818cf8 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
background: #1e232e;
|
||||||
|
border: 1px solid #2e3646;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #10b981;
|
||||||
|
box-shadow: 0 0 6px #10b981;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.status-dot.disconnected {
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 6px #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网址推送卡片 */
|
||||||
|
.push-url-card {
|
||||||
|
background: #1a1e28;
|
||||||
|
border: 1px solid #2e3646;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.push-url-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #38bdf8;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.push-url-title svg {
|
||||||
|
width: 15px; height: 15px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
.push-url-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.push-url-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #3b4556;
|
||||||
|
background: #11141c;
|
||||||
|
color: #f8fafc;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.push-url-row input:focus {
|
||||||
|
border-color: #38bdf8;
|
||||||
|
box-shadow: 0 0 0 2px rgba(56, 189, 248, 0.2);
|
||||||
|
}
|
||||||
|
.push-url-row button {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: linear-gradient(135deg, #0284c7 0%, #2563eb 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s, transform 0.1s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.push-url-row button:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 触控板 */
|
||||||
|
.touchpad {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 180px;
|
||||||
|
max-height: 260px;
|
||||||
|
background: #161922;
|
||||||
|
border: 1px solid #2a3140;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.touchpad p {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #64748b;
|
||||||
|
margin: 3px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.touchpad .hint-icon {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.touchpad .hint-icon svg {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
fill: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文本输入条 */
|
||||||
|
.text-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.text-input input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #333d4e;
|
||||||
|
background: #181c25;
|
||||||
|
color: #f1f5f9;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.text-input input:focus {
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
.text-input button {
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #3e4859;
|
||||||
|
background: #232834;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.text-input button:active { background: #323a4b; }
|
||||||
|
|
||||||
|
/* 按键网格 (4列 x 3行) */
|
||||||
|
.buttons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 4px;
|
||||||
|
background: #1c212c;
|
||||||
|
border: 1px solid #2a3342;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
transition: background 0.15s, transform 0.1s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.btn:active {
|
||||||
|
background: #2c3546;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
.btn svg {
|
||||||
|
width: 24px; height: 24px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
.btn.btn-accent {
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
.btn.btn-warn {
|
||||||
|
color: #f43f5e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #475569;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<span class="title">小北浏览器 · 遥控器</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-bar">
|
||||||
|
<span class="status-dot" id="statusDot"></span>
|
||||||
|
<span id="statusText">正在连接电视...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 网址推送板块 -->
|
||||||
|
<div class="push-url-card">
|
||||||
|
<div class="push-url-title">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></svg>
|
||||||
|
<span>一键推送链接到电视</span>
|
||||||
|
</div>
|
||||||
|
<div class="push-url-row">
|
||||||
|
<input id="urlInput" type="url" placeholder="粘贴网址 (如 bilibili.com 或视频页)" />
|
||||||
|
<button id="btnPushUrl">🚀 推送</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件/APK快传卡片 (P5) -->
|
||||||
|
<div class="push-url-card file-upload-card">
|
||||||
|
<div class="push-url-title" style="color: #a78bfa;">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96zM14 13v4h-4v-4H7l5-5 5 5h-3z"/></svg>
|
||||||
|
<span>无线快传 (APK / 视频 / 文件到电视)</span>
|
||||||
|
</div>
|
||||||
|
<div class="push-url-row" style="align-items: center;">
|
||||||
|
<input type="file" id="fileInput" style="display:none;" />
|
||||||
|
<button id="btnChooseFile" style="flex: 1; background: linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%);">📁 选择文件并发送</button>
|
||||||
|
</div>
|
||||||
|
<div id="uploadProgressContainer" style="display:none; margin-top: 8px;">
|
||||||
|
<div style="background:#11141c; height:6px; border-radius:3px; overflow:hidden;">
|
||||||
|
<div id="uploadProgressBar" style="width:0%; height:100%; background:#10b981; transition:width 0.15s;"></div>
|
||||||
|
</div>
|
||||||
|
<div id="uploadProgressText" style="font-size:12px; color:#94a3b8; margin-top:4px; text-align:center;">准备发送...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 触控板 -->
|
||||||
|
<div class="touchpad" id="touchpad">
|
||||||
|
<div class="hint-icon">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M9 11.24V7.5a2.5 2.5 0 0 1 5 0v3.74c1.21-.81 2-2.18 2-3.74C16 5.01 13.99 3 11.5 3S7 5.01 7 7.5c0 1.56.79 2.93 2 3.74zm9.84 4.63l-4.54-2.26c-.17-.07-.35-.11-.54-.11H13v-6c0-.83-.67-1.5-1.5-1.5S10 6.67 10 7.5v10.74l-3.44-.72c-.09-.02-.18-.02-.28-.02-.38 0-.73.16-.98.41l-.7.7 5.02 5.02c.36.36.86.57 1.38.57h6.99c.97 0 1.79-.69 1.96-1.64l.87-4.99c.14-.8-.32-1.58-.98-1.94z"/></svg>
|
||||||
|
</div>
|
||||||
|
<p>单指滑动移动光标 · 轻击确认</p>
|
||||||
|
<p>双指上下滑动滚动网页</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文本输入发送到电视 -->
|
||||||
|
<div class="text-input">
|
||||||
|
<input id="textInput" type="text" placeholder="向电视当前焦点输入框打字..." />
|
||||||
|
<button id="btnSendText">发送</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 遥控按键(第一排:系统控制;第二排:媒体/音量;第三排:方向键) -->
|
||||||
|
<div class="buttons">
|
||||||
|
<!-- 行 1: 常用控制 -->
|
||||||
|
<button class="btn" data-key="back" title="返回">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-accent" data-key="home" title="主页">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>
|
||||||
|
主页
|
||||||
|
</button>
|
||||||
|
<button class="btn" data-key="menu" title="菜单">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
|
||||||
|
菜单
|
||||||
|
</button>
|
||||||
|
<button class="btn" data-key="refresh" title="刷新">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 行 2: 音量与全屏 -->
|
||||||
|
<button class="btn" data-key="volume_down" title="音量-">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z"/></svg>
|
||||||
|
音量-
|
||||||
|
</button>
|
||||||
|
<button class="btn" data-key="volume_up" title="音量+">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/></svg>
|
||||||
|
音量+
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-warn" data-key="mute" title="静音">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27l4.73 4.73H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
|
||||||
|
静音
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-accent" data-key="fullscreen" title="全屏">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
|
||||||
|
全屏
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 行 2.5: 影视增强(原生播放与外部播放) -->
|
||||||
|
<button class="btn btn-accent" data-key="native_play" title="原生全屏播放" style="grid-column: span 2; flex-direction: row; gap: 6px; font-weight: 600; padding: 10px 8px;">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 14.5v-9l6 4.5-6 4.5z"/></svg>
|
||||||
|
🎬 原生播放
|
||||||
|
</button>
|
||||||
|
<button class="btn" data-key="external_play" title="调用外部播放器" style="grid-column: span 2; flex-direction: row; gap: 6px; padding: 10px 8px;">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 1.99-.9 1.99-2L23 5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/></svg>
|
||||||
|
📺 外部播放
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 行 3: 方向导航 -->
|
||||||
|
<button class="btn" data-key="dpad_up" title="上键">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6-6 6z"/></svg>
|
||||||
|
上键
|
||||||
|
</button>
|
||||||
|
<button class="btn" data-key="dpad_down" title="下键">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/></svg>
|
||||||
|
下键
|
||||||
|
</button>
|
||||||
|
<button class="btn" data-key="dpad_left" title="左键">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
|
||||||
|
左键
|
||||||
|
</button>
|
||||||
|
<button class="btn" data-key="dpad_right" title="右键">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
|
||||||
|
右键
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">小北浏览器 · 局域网触屏遥控</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var base = window.location.origin;
|
||||||
|
var cmdUrl = base + '/cmd';
|
||||||
|
var statusDot = document.getElementById('statusDot');
|
||||||
|
var statusText = document.getElementById('statusText');
|
||||||
|
|
||||||
|
function send(body) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', cmdUrl, true);
|
||||||
|
xhr.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
xhr.onreadystatechange = function() {
|
||||||
|
if (xhr.readyState === 4) {
|
||||||
|
var ok = (xhr.status === 200);
|
||||||
|
statusDot.classList.toggle('disconnected', !ok);
|
||||||
|
statusText.textContent = ok ? '已连接电视' : '连接异常,请重试';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.send(JSON.stringify(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 网址推送 ---
|
||||||
|
var urlInput = document.getElementById('urlInput');
|
||||||
|
var btnPushUrl = document.getElementById('btnPushUrl');
|
||||||
|
function pushUrl() {
|
||||||
|
var val = urlInput.value.trim();
|
||||||
|
if (!val) return;
|
||||||
|
send({ type: 'open_url', url: val });
|
||||||
|
var originalText = btnPushUrl.textContent;
|
||||||
|
btnPushUrl.textContent = '✓ 已推送';
|
||||||
|
btnPushUrl.style.background = '#10b981';
|
||||||
|
setTimeout(function() {
|
||||||
|
btnPushUrl.textContent = originalText;
|
||||||
|
btnPushUrl.style.background = '';
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
btnPushUrl.addEventListener('click', pushUrl);
|
||||||
|
urlInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
pushUrl();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 文件/APK 无线快传 (P5) ---
|
||||||
|
var fileInput = document.getElementById('fileInput');
|
||||||
|
var btnChooseFile = document.getElementById('btnChooseFile');
|
||||||
|
var uploadProgressContainer = document.getElementById('uploadProgressContainer');
|
||||||
|
var uploadProgressBar = document.getElementById('uploadProgressBar');
|
||||||
|
var uploadProgressText = document.getElementById('uploadProgressText');
|
||||||
|
|
||||||
|
btnChooseFile.addEventListener('click', function() {
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', function() {
|
||||||
|
var file = fileInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
var uploadUrl = base + '/upload?filename=' + encodeURIComponent(file.name);
|
||||||
|
xhr.open('POST', uploadUrl, true);
|
||||||
|
|
||||||
|
uploadProgressContainer.style.display = 'block';
|
||||||
|
uploadProgressBar.style.width = '0%';
|
||||||
|
uploadProgressText.textContent = '准备发送: ' + file.name;
|
||||||
|
btnChooseFile.disabled = true;
|
||||||
|
btnChooseFile.style.opacity = '0.6';
|
||||||
|
|
||||||
|
xhr.upload.onprogress = function(e) {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
var percent = Math.round((e.loaded / e.total) * 100);
|
||||||
|
uploadProgressBar.style.width = percent + '%';
|
||||||
|
uploadProgressText.textContent = '正在发送 ' + file.name + ' (' + percent + '%)';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = function() {
|
||||||
|
btnChooseFile.disabled = false;
|
||||||
|
btnChooseFile.style.opacity = '1';
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
uploadProgressBar.style.width = '100%';
|
||||||
|
uploadProgressText.textContent = '✓ 发送成功!电视端已接收并提示';
|
||||||
|
uploadProgressText.style.color = '#10b981';
|
||||||
|
setTimeout(function() {
|
||||||
|
uploadProgressContainer.style.display = 'none';
|
||||||
|
uploadProgressText.style.color = '#94a3b8';
|
||||||
|
fileInput.value = '';
|
||||||
|
}, 4000);
|
||||||
|
} else {
|
||||||
|
uploadProgressText.textContent = '❌ 传输失败,请重试';
|
||||||
|
uploadProgressText.style.color = '#ef4444';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = function() {
|
||||||
|
btnChooseFile.disabled = false;
|
||||||
|
btnChooseFile.style.opacity = '1';
|
||||||
|
uploadProgressText.textContent = '❌ 网络异常,传输中断';
|
||||||
|
uploadProgressText.style.color = '#ef4444';
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 触控板 ---
|
||||||
|
var touchpad = document.getElementById('touchpad');
|
||||||
|
var lastX = 0, lastY = 0;
|
||||||
|
var moveThrottle = 0;
|
||||||
|
var touchMoved = false;
|
||||||
|
var speedFactor = 4; // 移动速度倍数
|
||||||
|
var twoFingerInitialized = false;
|
||||||
|
|
||||||
|
touchpad.addEventListener('touchstart', function(e) {
|
||||||
|
if (e.touches.length === 1) {
|
||||||
|
lastX = e.touches[0].clientX;
|
||||||
|
lastY = e.touches[0].clientY;
|
||||||
|
touchMoved = false;
|
||||||
|
twoFingerInitialized = false;
|
||||||
|
} else if (e.touches.length >= 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
var cx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
||||||
|
var cy = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
||||||
|
lastX = cx;
|
||||||
|
lastY = cy;
|
||||||
|
twoFingerInitialized = true;
|
||||||
|
touchMoved = true;
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
touchpad.addEventListener('touchmove', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
var cx = (e.touches[0].clientX + e.touches[1].clientX) / 2;
|
||||||
|
var cy = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
||||||
|
if (!twoFingerInitialized) {
|
||||||
|
lastX = cx;
|
||||||
|
lastY = cy;
|
||||||
|
twoFingerInitialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var dx = Math.round(cx - lastX);
|
||||||
|
var dy = Math.round(cy - lastY);
|
||||||
|
lastX = cx;
|
||||||
|
lastY = cy;
|
||||||
|
if (dx !== 0 || dy !== 0) send({ type: 'scroll', dx: dx, dy: dy });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
twoFingerInitialized = false;
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
var dx = Math.round((e.touches[0].clientX - lastX) * speedFactor);
|
||||||
|
var dy = Math.round((e.touches[0].clientY - lastY) * speedFactor);
|
||||||
|
lastX = e.touches[0].clientX;
|
||||||
|
lastY = e.touches[0].clientY;
|
||||||
|
if (dx === 0 && dy === 0) return;
|
||||||
|
touchMoved = true;
|
||||||
|
if (moveThrottle) return;
|
||||||
|
moveThrottle = setTimeout(function() { moveThrottle = 0; }, 30);
|
||||||
|
send({ type: 'move', dx: dx, dy: dy });
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
touchpad.addEventListener('touchend', function(e) {
|
||||||
|
if (e.touches.length < 2) twoFingerInitialized = false;
|
||||||
|
if (e.changedTouches.length === 1 && e.touches.length === 0) {
|
||||||
|
if (!touchMoved) {
|
||||||
|
send({ type: 'click' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// 电脑端鼠标模拟测试
|
||||||
|
var mouseMoved = false;
|
||||||
|
touchpad.addEventListener('mousedown', function(e) {
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
mouseMoved = false;
|
||||||
|
});
|
||||||
|
touchpad.addEventListener('mousemove', function(e) {
|
||||||
|
if (e.buttons !== 1) return;
|
||||||
|
var dx = Math.round((e.clientX - lastX) * speedFactor);
|
||||||
|
var dy = Math.round((e.clientY - lastY) * speedFactor);
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
if (dx !== 0 || dy !== 0) {
|
||||||
|
mouseMoved = true;
|
||||||
|
send({ type: 'move', dx: dx, dy: dy });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
touchpad.addEventListener('mouseup', function(e) {
|
||||||
|
if (e.button === 0 && !mouseMoved) send({ type: 'click' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 按钮组 ---
|
||||||
|
document.querySelectorAll('.btn').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var key = this.getAttribute('data-key');
|
||||||
|
if (key) send({ type: 'key', keyCode: key });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 文本输入 ---
|
||||||
|
var textInput = document.getElementById('textInput');
|
||||||
|
var btnSendText = document.getElementById('btnSendText');
|
||||||
|
function sendText() {
|
||||||
|
var value = textInput.value;
|
||||||
|
if (!value) return;
|
||||||
|
send({ type: 'text', text: value });
|
||||||
|
}
|
||||||
|
btnSendText.addEventListener('click', sendText);
|
||||||
|
textInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
sendText();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 键盘事件监听 ---
|
||||||
|
var keyToCmd = { 'ArrowUp': 'dpad_up', 'ArrowDown': 'dpad_down', 'ArrowLeft': 'dpad_left', 'ArrowRight': 'dpad_right' };
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
var cmd = keyToCmd[e.key];
|
||||||
|
if (cmd) {
|
||||||
|
send({ type: 'key', keyCode: cmd });
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.handleDpadKey = function(key) {
|
||||||
|
var cmd = keyToCmd[key];
|
||||||
|
if (cmd) {
|
||||||
|
send({ type: 'key', keyCode: cmd });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 首次状态连接检查
|
||||||
|
var check = new XMLHttpRequest();
|
||||||
|
check.open('POST', cmdUrl, true);
|
||||||
|
check.setRequestHeader('Content-Type', 'application/json');
|
||||||
|
check.onload = function() {
|
||||||
|
statusDot.classList.remove('disconnected');
|
||||||
|
statusText.textContent = '已连接电视';
|
||||||
|
};
|
||||||
|
check.onerror = function() {
|
||||||
|
statusDot.classList.add('disconnected');
|
||||||
|
statusText.textContent = '未连接 (请确保手机与电视在同一 WiFi)';
|
||||||
|
};
|
||||||
|
check.send(JSON.stringify({ type: 'ping' }));
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 影视站轻量级去广告与防劫持工具类:
|
||||||
|
* 1. 域名与资源拦截(百度网盟、Tanx、Google Ads、统计代码等)
|
||||||
|
* 2. 恶意跳转协议拦截(阻止 tbopen://, alipays:// 等导致白屏或弹窗错误)
|
||||||
|
* 3. 动态 CSS 元素隐藏注入(清理悬浮横幅、弹窗遮罩、伪造全屏按钮)
|
||||||
|
* 4. 禁用恶意 window.open 弹窗劫持
|
||||||
|
*/
|
||||||
|
object AdBlockHelper {
|
||||||
|
|
||||||
|
private const val TAG = "TVBrowser.AdBlock"
|
||||||
|
private const val PREFS_NAME = "tv_browser_adblock"
|
||||||
|
private const val KEY_ADBLOCK_ENABLED = "adblock_enabled"
|
||||||
|
|
||||||
|
/** 常见广告与跟踪域名黑名单关键词 */
|
||||||
|
private val AD_HOST_PATTERNS = arrayOf(
|
||||||
|
"pos.baidu.com",
|
||||||
|
"cpro.baidustatic.com",
|
||||||
|
"mobads.baidu.com",
|
||||||
|
"tanx.com",
|
||||||
|
"alimama.com",
|
||||||
|
"strip.alicdn.com",
|
||||||
|
"mi.gdt.qq.com",
|
||||||
|
"pgdt.gtimg.cn",
|
||||||
|
"hm.baidu.com",
|
||||||
|
"cnzz.com",
|
||||||
|
"umeng.com",
|
||||||
|
"51.la",
|
||||||
|
"googlesyndication.com",
|
||||||
|
"googleadservices.com",
|
||||||
|
"pagead2.googlesyndication.com",
|
||||||
|
"doubleclick.net",
|
||||||
|
"adservice",
|
||||||
|
"adsystem",
|
||||||
|
"adx.",
|
||||||
|
"adnxs",
|
||||||
|
"union.baidu.com",
|
||||||
|
"union.sogou.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 常见恶意拦截跳转 App 协议头(电视端通常无对应 App,会导致 ERR_UNKNOWN_URL_SCHEME 报错白屏) */
|
||||||
|
private val BLOCKED_SCHEMES = arrayOf(
|
||||||
|
"tbopen",
|
||||||
|
"alipays",
|
||||||
|
"alipay",
|
||||||
|
"weixin",
|
||||||
|
"pddopen",
|
||||||
|
"snssdk",
|
||||||
|
"kwai",
|
||||||
|
"openapp.jdmobile",
|
||||||
|
"meituanwaimai",
|
||||||
|
"dianping",
|
||||||
|
"vipshop",
|
||||||
|
"market",
|
||||||
|
"intent"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 是否开启去广告(默认开启) */
|
||||||
|
fun isEnabled(context: Context): Boolean {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return prefs.getBoolean(KEY_ADBLOCK_ENABLED, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置去广告开关 */
|
||||||
|
fun setEnabled(context: Context, enabled: Boolean) {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit().putBoolean(KEY_ADBLOCK_ENABLED, enabled).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查 URL 是否为广告网络请求 */
|
||||||
|
fun shouldBlockUrl(url: String): Boolean {
|
||||||
|
val lower = url.lowercase()
|
||||||
|
return AD_HOST_PATTERNS.any { pattern -> lower.contains(pattern) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成空响应拦截广告请求 */
|
||||||
|
fun createEmptyResponse(): WebResourceResponse {
|
||||||
|
return WebResourceResponse(
|
||||||
|
"text/plain",
|
||||||
|
"UTF-8",
|
||||||
|
ByteArrayInputStream(ByteArray(0))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查 URL 是否属于恶意广告跳转协议 */
|
||||||
|
fun isHijackScheme(url: String): Boolean {
|
||||||
|
val lower = url.lowercase().trim()
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强力元素隐藏与弹窗防劫持注入脚本:
|
||||||
|
* 1. 隐藏高层级遮罩、悬浮关闭广告、通用横幅容器
|
||||||
|
* 2. 覆盖 window.open,防止影视网站静默弹窗跳出
|
||||||
|
* 3. MutationObserver 监听动态插入的广告节点
|
||||||
|
*/
|
||||||
|
val JS_INJECT_ADBLOCK = """
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
if (window.__tvAdBlockInjected) return;
|
||||||
|
window.__tvAdBlockInjected = true;
|
||||||
|
|
||||||
|
// 1. 阻止未经用户主动操作的 window.open 弹窗劫持
|
||||||
|
var origOpen = window.open;
|
||||||
|
window.open = function(url, target, features) {
|
||||||
|
console.log('TVBrowser AdBlock: blocked popup window.open ->', url);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 注入全局去广告 CSS 规则
|
||||||
|
var css = [
|
||||||
|
'[id*="google_ads"]', '[class*="google_ads"]',
|
||||||
|
'[id*="pos_"]', '[class*="ad-banner"]', '[id*="pop-ad"]', '[class*="pop-ad"]',
|
||||||
|
'[id*="adv-"]', '[class*="adv-"]', '[id*="_ad_"]', '[class*="_ad_"]',
|
||||||
|
'div[style*="z-index: 999999"]', 'div[style*="z-index: 2147483647"]',
|
||||||
|
'.popup-box', '.app-download-bar', '.bottom-fixed-ad', '.float-ad',
|
||||||
|
'iframe[src*="union"]', 'iframe[src*="ad."]', 'iframe[src*="/ad/"]'
|
||||||
|
].join(', ') + ' { display: none !important; width: 0 !important; height: 0 !important; visibility: hidden !important; pointer-events: none !important; }';
|
||||||
|
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.type = 'text/css';
|
||||||
|
style.id = '__tv_adblock_css';
|
||||||
|
style.appendChild(document.createTextNode(css));
|
||||||
|
(document.head || document.documentElement).appendChild(style);
|
||||||
|
|
||||||
|
// 3. 动态监听并隐藏常见弹层遮罩
|
||||||
|
function cleanPopups() {
|
||||||
|
var popups = document.querySelectorAll('div[style*="z-index"], iframe[src*="ad"]');
|
||||||
|
for (var i = 0; i < popups.length; i++) {
|
||||||
|
var el = popups[i];
|
||||||
|
var z = parseInt(window.getComputedStyle(el).zIndex) || 0;
|
||||||
|
if (z >= 99999 && !el.querySelector('video')) {
|
||||||
|
el.style.setProperty('display', 'none', 'important');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleanPopups();
|
||||||
|
try {
|
||||||
|
new MutationObserver(cleanPopups).observe(document.documentElement || document.body, { childList: true, subtree: true });
|
||||||
|
} catch(e) {}
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用级小设置:
|
||||||
|
* - 自动接管播放(嗅探到视频后倒计时自动进入原生播放器)
|
||||||
|
* - 按剧记住的片头 / 片尾时长(跳过片头片尾)
|
||||||
|
*/
|
||||||
|
object AppSettings {
|
||||||
|
private const val PREFS = "tv_browser_settings"
|
||||||
|
private const val KEY_AUTO_TAKEOVER = "auto_takeover"
|
||||||
|
private const val KEY_DECODER_MODE = "decoder_mode"
|
||||||
|
private const val KEY_PRELOAD_NEXT = "preload_next"
|
||||||
|
private const val KEY_PURIFY_PAGE = "purify_page"
|
||||||
|
private const val SKIP_PREFIX = "skip|"
|
||||||
|
|
||||||
|
/** 解码兼容模式 */
|
||||||
|
const val DECODER_AUTO = 0 // 自动(默认推荐 · 硬件优先,失败回退)
|
||||||
|
const val DECODER_HARDWARE = 1 // 强制硬解(GPU加速 · 省电高效)
|
||||||
|
const val DECODER_SOFTWARE = 2 // 强制软解(CPU解码 · 解决花屏/黑屏/绿屏)
|
||||||
|
|
||||||
|
private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun isAutoTakeover(context: Context): Boolean = prefs(context).getBoolean(KEY_AUTO_TAKEOVER, true)
|
||||||
|
|
||||||
|
fun setAutoTakeover(context: Context, enabled: Boolean) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_AUTO_TAKEOVER, enabled).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDecoderMode(context: Context): Int = prefs(context).getInt(KEY_DECODER_MODE, DECODER_AUTO)
|
||||||
|
|
||||||
|
fun setDecoderMode(context: Context, mode: Int) {
|
||||||
|
prefs(context).edit().putInt(KEY_DECODER_MODE, mode).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decoderModeName(mode: Int): String = when (mode) {
|
||||||
|
DECODER_HARDWARE -> "强制硬解 (GPU)"
|
||||||
|
DECODER_SOFTWARE -> "强制软解 (CPU)"
|
||||||
|
else -> "自动"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPreloadNext(context: Context): Boolean = prefs(context).getBoolean(KEY_PRELOAD_NEXT, true)
|
||||||
|
|
||||||
|
fun setPreloadNext(context: Context, enabled: Boolean) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_PRELOAD_NEXT, enabled).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 净化播放页(默认关闭):识别到影视播放页时只保留播放器、线路与选集 */
|
||||||
|
fun isPurifyPage(context: Context): Boolean = prefs(context).getBoolean(KEY_PURIFY_PAGE, false)
|
||||||
|
|
||||||
|
fun setPurifyPage(context: Context, enabled: Boolean) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_PURIFY_PAGE, enabled).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 片头片尾设置:introMs = 片头结束位置;outroMs = 片尾长度(距结尾多少毫秒开始算片尾) */
|
||||||
|
data class SkipConfig(val introMs: Long, val outroMs: Long) {
|
||||||
|
val isEmpty: Boolean get() = introMs <= 0 && outroMs <= 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSkip(context: Context, seriesKey: String): SkipConfig {
|
||||||
|
if (seriesKey.isBlank()) return SkipConfig(0, 0)
|
||||||
|
val raw = prefs(context).getString(SKIP_PREFIX + seriesKey, null) ?: return SkipConfig(0, 0)
|
||||||
|
val parts = raw.split(',')
|
||||||
|
return SkipConfig(parts.getOrNull(0)?.toLongOrNull() ?: 0, parts.getOrNull(1)?.toLongOrNull() ?: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSkip(context: Context, seriesKey: String, config: SkipConfig) {
|
||||||
|
if (seriesKey.isBlank()) return
|
||||||
|
val editor = prefs(context).edit()
|
||||||
|
if (config.isEmpty) editor.remove(SKIP_PREFIX + seriesKey)
|
||||||
|
else editor.putString(SKIP_PREFIX + seriesKey, "${config.introMs.coerceAtLeast(0)},${config.outroMs.coerceAtLeast(0)}")
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空所有剧集的片头片尾跳过记忆 */
|
||||||
|
fun clearAllSkips(context: Context) {
|
||||||
|
val p = prefs(context)
|
||||||
|
val editor = p.edit()
|
||||||
|
for (key in p.all.keys) {
|
||||||
|
if (key.startsWith(SKIP_PREFIX)) {
|
||||||
|
editor.remove(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 剧集分组 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 domain = ContentFilterHelper.registrableDomain(host) ?: return ""
|
||||||
|
return "$domain|${seriesTitle.trim()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 收藏项:URL 与标题。
|
||||||
|
*/
|
||||||
|
data class Bookmark(
|
||||||
|
val url: String,
|
||||||
|
val title: String
|
||||||
|
) {
|
||||||
|
fun displayTitle(): String = if (title.isNotBlank()) title else url
|
||||||
|
|
||||||
|
fun displayHost(): String {
|
||||||
|
return try {
|
||||||
|
val uri = Uri.parse(url)
|
||||||
|
uri.host?.removePrefix("www.") ?: url
|
||||||
|
} catch (_: Exception) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 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"
|
||||||
|
|
||||||
|
fun getAll(): List<Bookmark> {
|
||||||
|
val isInitialized = prefs.getBoolean(keyInitialized, false)
|
||||||
|
val json = prefs.getString(keyBookmarks, null)
|
||||||
|
if (!isInitialized && json == null) {
|
||||||
|
save(DEFAULT_BOOKMARKS)
|
||||||
|
prefs.edit()
|
||||||
|
.putBoolean(keyInitialized, true)
|
||||||
|
.putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION)
|
||||||
|
.apply()
|
||||||
|
return DEFAULT_BOOKMARKS
|
||||||
|
}
|
||||||
|
val list = parse(json)
|
||||||
|
return migrateDefaults(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(json: String?): List<Bookmark> {
|
||||||
|
if (json.isNullOrBlank()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
List(arr.length()) { i ->
|
||||||
|
val obj = arr.getJSONObject(i)
|
||||||
|
Bookmark(
|
||||||
|
url = obj.getString(KEY_URL),
|
||||||
|
title = obj.optString(KEY_TITLE, "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 老用户升级:把之后版本新增的默认站点补到最前面(只补一次) */
|
||||||
|
private fun migrateDefaults(list: List<Bookmark>): List<Bookmark> {
|
||||||
|
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)
|
||||||
|
prefs.edit().putInt(KEY_DEFAULTS_VERSION, DEFAULTS_VERSION).apply()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
fun add(bookmark: Bookmark) {
|
||||||
|
val list = getAll().toMutableList()
|
||||||
|
list.removeAll { it.url == bookmark.url }
|
||||||
|
list.add(0, bookmark)
|
||||||
|
save(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(url: String) {
|
||||||
|
val list = getAll().filter { it.url != url }
|
||||||
|
save(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun contains(url: String): Boolean = getAll().any { it.url == url }
|
||||||
|
|
||||||
|
private fun save(list: List<Bookmark>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
list.forEach { b ->
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put(KEY_URL, b.url)
|
||||||
|
put(KEY_TITLE, b.title)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
prefs.edit().putString(keyBookmarks, arr.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "tv_browser_bookmarks"
|
||||||
|
private const val KEY_URL = "url"
|
||||||
|
private const val KEY_TITLE = "title"
|
||||||
|
private const val KEY_DEFAULTS_VERSION = "bookmarks_defaults_version"
|
||||||
|
|
||||||
|
/** 默认收藏版本:新增默认站点时 +1,并在 [DEFAULTS_ADDED_BY_VERSION] 登记 */
|
||||||
|
private const val DEFAULTS_VERSION = 4
|
||||||
|
|
||||||
|
/** 各版本新增的默认站点:老用户升级时一次性补入(已存在则跳过,之后被删也不再补) */
|
||||||
|
private val DEFAULTS_ADDED_BY_VERSION = mapOf(
|
||||||
|
2 to listOf(Bookmark(url = "https://www.mtyy3.com/", title = "麦田影院")),
|
||||||
|
3 to listOf(
|
||||||
|
Bookmark(url = "https://www.1905.com/", title = "1950电影网"),
|
||||||
|
Bookmark(url = "http://www.docuchina.cn/", title = "中国纪录片网"),
|
||||||
|
Bookmark(url = "https://tv.cctv.com/", title = "CCTV节目官网_央视网"),
|
||||||
|
Bookmark(url = "https://yangshipin.cn/", title = "央视频 - 有品质的视频社交媒体")
|
||||||
|
),
|
||||||
|
4 to listOf(
|
||||||
|
Bookmark(url = "https://www.czzy77.com/", title = "厂长资源"),
|
||||||
|
Bookmark(url = "https://www.4kvm.tv/", title = "4K影视"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 收藏夹为空时默认加入的收藏 */
|
||||||
|
private val DEFAULT_BOOKMARKS = listOf(
|
||||||
|
Bookmark(url = "https://www.4kvm.tv/", title = "4K影视"),
|
||||||
|
Bookmark(url = "https://www.czzy77.com/", title = "厂长资源"),
|
||||||
|
Bookmark(url = "https://www.1905.com/", title = "1950电影网"),
|
||||||
|
Bookmark(url = "http://www.docuchina.cn/", title = "中国纪录片网"),
|
||||||
|
Bookmark(url = "https://tv.cctv.com/", title = "CCTV节目官网_央视网"),
|
||||||
|
Bookmark(url = "https://yangshipin.cn/", title = "央视频 - 有品质的视频社交媒体"),
|
||||||
|
Bookmark(url = "https://www.mtyy3.com/", title = "麦田影院"),
|
||||||
|
Bookmark(url = "https://www.ppnix.com/cn/", title = "ppnix"),
|
||||||
|
Bookmark(url = "https://www.ymck.pro/", title = "ymck"),
|
||||||
|
Bookmark(url = "https://www.ncat1.app/", title = "网肥猫"),
|
||||||
|
Bookmark(url = "https://www.keke1.app/", title = "可可"),
|
||||||
|
Bookmark(url = "https://www.dmxq19.com/index/home.html", title = "大米"),
|
||||||
|
Bookmark(url = "https://www.xuelewang.com/", title = "蜂鸟影院"),
|
||||||
|
Bookmark(url = "https://www.bjjq168.com/", title = "星辰影院"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 不良动图 / 色情广告过滤("强力过滤"):
|
||||||
|
* 1. 网络层:拦截第三方域名的 GIF 动图、路径带广告特征的图片(不下载,最省流量)
|
||||||
|
* 2. 页面结构层:注入脚本,按"外链 + 动图 + 横幅比例 + 悬浮 + 敏感词"打分,命中即隐藏
|
||||||
|
* 3. 站点规则:针对具体站点的屏蔽选择器
|
||||||
|
* 4. 站点白名单:误伤时可对单个站点关闭过滤
|
||||||
|
* 5. 无图模式:彻底不加载网络图片(兜底)
|
||||||
|
*/
|
||||||
|
object ContentFilterHelper {
|
||||||
|
|
||||||
|
private const val PREFS_NAME = "tv_browser_content_filter"
|
||||||
|
private const val KEY_STRONG_FILTER = "strong_filter_enabled"
|
||||||
|
private const val KEY_NO_IMAGE = "no_image_mode"
|
||||||
|
private const val KEY_WHITELIST = "filter_whitelist_hosts"
|
||||||
|
|
||||||
|
/** 广告图片常见路径特征 */
|
||||||
|
private val AD_PATH_PATTERNS = arrayOf(
|
||||||
|
"/ad/", "/ads/", "/adv/", "/advert", "/guanggao", "/gg/", "/banner",
|
||||||
|
"/union/", "/popup", "/tuiguang", "_ad.", "-ad.", "/ad_", "/ads_"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 视频相关路径关键词:命中时不按"可疑脚本"拦截,避免误伤解析接口 / 清单 / 分片 */
|
||||||
|
private val VIDEO_PATH_HINTS = arrayOf(
|
||||||
|
"m3u8", ".ts", ".mp4", ".key", ".json", "/api", "video", "play", "vod", "/jx", "parse", "/hls", "/dash"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 双段公共后缀(如 com.cn),用于粗略计算主域 */
|
||||||
|
private val SECOND_LEVEL_SUFFIX = setOf("com", "net", "org", "gov", "edu", "co", "ac")
|
||||||
|
|
||||||
|
// ---------------- 开关 ----------------
|
||||||
|
|
||||||
|
/** 强力过滤可疑动图(默认开启) */
|
||||||
|
fun isStrongFilterEnabled(context: Context): Boolean =
|
||||||
|
prefs(context).getBoolean(KEY_STRONG_FILTER, true)
|
||||||
|
|
||||||
|
fun setStrongFilterEnabled(context: Context, enabled: Boolean) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_STRONG_FILTER, enabled).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 无图模式(默认关闭) */
|
||||||
|
fun isNoImageMode(context: Context): Boolean =
|
||||||
|
prefs(context).getBoolean(KEY_NO_IMAGE, false)
|
||||||
|
|
||||||
|
fun setNoImageMode(context: Context, enabled: Boolean) {
|
||||||
|
prefs(context).edit().putBoolean(KEY_NO_IMAGE, enabled).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 站点是否在白名单(不过滤)中,按主域判断 */
|
||||||
|
fun isWhitelisted(context: Context, host: String?): Boolean {
|
||||||
|
val domain = registrableDomain(host) ?: return false
|
||||||
|
return prefs(context).getStringSet(KEY_WHITELIST, emptySet())?.contains(domain) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setWhitelisted(context: Context, host: String?, whitelisted: Boolean) {
|
||||||
|
val domain = registrableDomain(host) ?: return
|
||||||
|
val set = prefs(context).getStringSet(KEY_WHITELIST, emptySet()).orEmpty().toMutableSet()
|
||||||
|
if (whitelisted) set.add(domain) else set.remove(domain)
|
||||||
|
prefs(context).edit().putStringSet(KEY_WHITELIST, set).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前站点是否需要执行强力过滤 */
|
||||||
|
fun shouldFilter(context: Context, pageHost: String?): Boolean =
|
||||||
|
isStrongFilterEnabled(context) && !isWhitelisted(context, pageHost)
|
||||||
|
|
||||||
|
private fun prefs(context: Context) =
|
||||||
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
// ---------------- 网络层 ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断子资源请求是否为可疑广告图片:
|
||||||
|
* - 第三方域名(与页面、Referer 主域都不同)的 GIF
|
||||||
|
* - 任意域名下路径带广告特征的图片
|
||||||
|
* 调用方需先确认 [shouldFilter]。可在 WebView 后台线程调用。
|
||||||
|
*/
|
||||||
|
fun shouldBlockImage(request: WebResourceRequest, pageHost: String?): Boolean {
|
||||||
|
if (request.isForMainFrame) return false
|
||||||
|
val uri = request.url ?: return false
|
||||||
|
val path = (uri.path ?: "").lowercase(Locale.ROOT)
|
||||||
|
val accept = request.requestHeaders?.entries
|
||||||
|
?.firstOrNull { it.key.equals("Accept", ignoreCase = true) }?.value.orEmpty()
|
||||||
|
val isGif = path.endsWith(".gif")
|
||||||
|
val isImage = isGif || accept.startsWith("image/") ||
|
||||||
|
path.endsWith(".webp") || path.endsWith(".png") || path.endsWith(".jpg") || path.endsWith(".jpeg")
|
||||||
|
if (!isImage) return false
|
||||||
|
|
||||||
|
if (AD_PATH_PATTERNS.any { path.contains(it) }) return true
|
||||||
|
if (!isGif) return false
|
||||||
|
|
||||||
|
val reqDomain = registrableDomain(uri.host) ?: return false
|
||||||
|
val pageDomain = registrableDomain(pageHost)
|
||||||
|
val refererHost = request.requestHeaders?.entries
|
||||||
|
?.firstOrNull { it.key.equals("Referer", ignoreCase = true) }?.value
|
||||||
|
?.let { runCatching { Uri.parse(it).host }.getOrNull() }
|
||||||
|
val refererDomain = registrableDomain(refererHost)
|
||||||
|
return reqDomain != pageDomain && reqDomain != refererDomain
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 被拦截图片返回空 GIF 响应 */
|
||||||
|
fun createEmptyImageResponse(): WebResourceResponse =
|
||||||
|
WebResourceResponse("image/gif", null, ByteArrayInputStream(ByteArray(0)))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可疑第三方脚本:与页面主域不同、且使用非标准端口(如 :9003、:21233)的 JS。
|
||||||
|
* 这类脚本几乎都是广告联盟 / 点击劫持 / 统计,且常以同步方式放在 head 顶部,
|
||||||
|
* 服务器慢时会阻塞整页解析(表现为只显示顶栏、内容空白、点击无反应)。
|
||||||
|
* 调用方需先确认 [shouldFilter]。可在 WebView 后台线程调用。
|
||||||
|
*/
|
||||||
|
fun shouldBlockScript(request: WebResourceRequest, pageHost: String?): Boolean {
|
||||||
|
if (request.isForMainFrame) return false
|
||||||
|
val uri = request.url ?: return false
|
||||||
|
val port = uri.port
|
||||||
|
if (port == -1 || port == 80 || port == 443) return false
|
||||||
|
val path = (uri.path ?: "").lowercase(Locale.ROOT)
|
||||||
|
val headers = request.requestHeaders.orEmpty()
|
||||||
|
fun header(name: String) = headers.entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value.orEmpty()
|
||||||
|
val accept = header("Accept")
|
||||||
|
// iframe 页面(如解析播放器)、图片、视频、清单等都不拦(部分站点的视频 CDN / 解析接口就走非标准端口)
|
||||||
|
if (accept.contains("text/html") || accept.startsWith("image/") ||
|
||||||
|
accept.contains("video") || accept.contains("mpegurl")) return false
|
||||||
|
if (VIDEO_PATH_HINTS.any { path.contains(it) }) return false
|
||||||
|
// 只处理无扩展名或 .js 的地址(广告联盟脚本通常形如 /d/1198?c=1、/tjjs/?k=xxx)
|
||||||
|
val lastSeg = path.substringAfterLast('/')
|
||||||
|
if (lastSeg.contains('.') && !lastSeg.endsWith(".js")) return false
|
||||||
|
val pageDomain = registrableDomain(pageHost)
|
||||||
|
val reqDomain = registrableDomain(uri.host) ?: return false
|
||||||
|
if (reqDomain == pageDomain) return false
|
||||||
|
// 只拦主页面直接发起的请求;播放器 iframe 内部的请求 Referer 是播放器自己的域名
|
||||||
|
val refererDomain = registrableDomain(runCatching { Uri.parse(header("Referer")).host }.getOrNull())
|
||||||
|
if (refererDomain != null && refererDomain != pageDomain) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 被拦截脚本返回空 JS */
|
||||||
|
fun createEmptyScriptResponse(): WebResourceResponse =
|
||||||
|
WebResourceResponse("application/javascript", "utf-8", ByteArrayInputStream(ByteArray(0)))
|
||||||
|
|
||||||
|
/** 粗略计算主域:a.b.example.com -> example.com,x.example.com.cn -> example.com.cn */
|
||||||
|
fun registrableDomain(host: String?): String? {
|
||||||
|
val h = host?.trim()?.lowercase(Locale.ROOT)?.trimEnd('.') ?: return null
|
||||||
|
if (h.isEmpty()) return null
|
||||||
|
if (h.all { it.isDigit() || it == '.' } || h.contains(':')) return h // IP
|
||||||
|
val parts = h.split('.')
|
||||||
|
if (parts.size <= 2) return h
|
||||||
|
val tld = parts[parts.size - 1]
|
||||||
|
val sld = parts[parts.size - 2]
|
||||||
|
val take = if (tld.length == 2 && sld in SECOND_LEVEL_SUFFIX) 3 else 2
|
||||||
|
return parts.takeLast(take.coerceAtMost(parts.size)).joinToString(".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 页面结构层 ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面过滤脚本:可重复注入(内部防重),持续监听动态插入的节点。
|
||||||
|
* 打分规则(≥3 分隐藏):
|
||||||
|
* GIF 图 +2;链接指向外站 +2;无真实链接(javascript:/无 href 但可点击) +1;
|
||||||
|
* 横幅比例(宽≥3×高) +1;位于 fixed/sticky 悬浮层 +1;第三方 iframe +1;
|
||||||
|
* 文字/链接含博彩色情等敏感词 +3;链接指向本站 -2;含 video 的区域直接跳过。
|
||||||
|
*/
|
||||||
|
val JS_CONTENT_FILTER = """
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
if (window.__tvContentFilter) { window.__tvContentFilter(); return; }
|
||||||
|
var SITE_RULES = {
|
||||||
|
'mtyy3.com': ['.ads_w', '#popup-container'],
|
||||||
|
'mtyy1.cc': ['.ads_w', '#popup-container']
|
||||||
|
};
|
||||||
|
var GENERIC_SELECTORS = [
|
||||||
|
'.ads_w', '.ads-box', '.ad-box', '.adsbox', '.ad_box', '.gg-box',
|
||||||
|
'[class*="guanggao"]', '[id*="guanggao"]', '[class*="ads_img"]',
|
||||||
|
'[class^="ads_"]', '[id^="ads_"]', '[id^="ad_"]', '[class*=" ads_"]',
|
||||||
|
'a[href*="/ad/"] > img', 'a[href*="/ads/"] > img'
|
||||||
|
];
|
||||||
|
var BAD_WORDS = /(色情|成人|约炮|裸聊|裸体|激情|少妇|人妻|萝莉|美女直播|一夜情|同城|上门|丝袜|福利姬|黄色|av女优|博彩|赌场|棋牌|彩票|百家乐|澳门|葡京|威尼斯|金沙|太阳城|开元|体育投注|真人娱乐|老虎机|下注|充值送|首充|捕鱼|电子游艺|xvideos|pornhub|onlyfans)/i;
|
||||||
|
|
||||||
|
function domainOf(host) {
|
||||||
|
host = (host || '').toLowerCase().replace(/\.$/, '');
|
||||||
|
if (!host || /^[\d.]+$/.test(host)) return host;
|
||||||
|
var p = host.split('.');
|
||||||
|
if (p.length <= 2) return host;
|
||||||
|
var tld = p[p.length - 1], sld = p[p.length - 2];
|
||||||
|
var take = (tld.length === 2 && /^(com|net|org|gov|edu|co|ac)$/.test(sld)) ? 3 : 2;
|
||||||
|
return p.slice(-take).join('.');
|
||||||
|
}
|
||||||
|
var pageDomain = domainOf(location.hostname);
|
||||||
|
|
||||||
|
function hostOf(url) {
|
||||||
|
try { return new URL(url, location.href).hostname; } catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
function isExternal(url) {
|
||||||
|
if (!url || !/^(https?:)?\/\//i.test(url)) return false;
|
||||||
|
var d = domainOf(hostOf(url));
|
||||||
|
return !!d && d !== pageDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide(el, reason) {
|
||||||
|
if (!el || el.__tvFiltered) return;
|
||||||
|
el.__tvFiltered = true;
|
||||||
|
el.setAttribute('data-tv-filtered', reason || '1');
|
||||||
|
el.style.setProperty('display', 'none', 'important');
|
||||||
|
el.style.setProperty('visibility', 'hidden', 'important');
|
||||||
|
window.__tvFilteredCount = (window.__tvFilteredCount || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 站点规则 + 通用广告容器:CSS 立即生效
|
||||||
|
var selectors = GENERIC_SELECTORS.slice();
|
||||||
|
Object.keys(SITE_RULES).forEach(function(d) {
|
||||||
|
if (pageDomain === d) selectors = selectors.concat(SITE_RULES[d]);
|
||||||
|
});
|
||||||
|
if (!document.getElementById('__tv_content_filter_css')) {
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.id = '__tv_content_filter_css';
|
||||||
|
style.appendChild(document.createTextNode(selectors.join(',') +
|
||||||
|
'{display:none !important;visibility:hidden !important;}' +
|
||||||
|
// WOW.js 滚动动画:内容先隐藏、滚动到位才显示,脚本慢/报错时会一直看不见也点不到
|
||||||
|
'.wow{visibility:visible !important;animation-name:none !important;}'));
|
||||||
|
(document.head || document.documentElement).appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inFixedLayer(el) {
|
||||||
|
var n = el, depth = 0;
|
||||||
|
while (n && n !== document.body && depth < 8) {
|
||||||
|
var pos = '';
|
||||||
|
try { pos = window.getComputedStyle(n).position; } catch (e) {}
|
||||||
|
if (pos === 'fixed' || pos === 'sticky') return true;
|
||||||
|
n = n.parentElement; depth++;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function nearVideo(el) {
|
||||||
|
var n = el, depth = 0;
|
||||||
|
while (n && depth < 4) {
|
||||||
|
if (n.tagName === 'VIDEO' || (n.querySelector && n.querySelector('video'))) return true;
|
||||||
|
n = n.parentElement; depth++;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function bgImage(el) {
|
||||||
|
try {
|
||||||
|
var bg = window.getComputedStyle(el).backgroundImage || '';
|
||||||
|
var m = bg.match(/url\(["']?([^"')]+)["']?\)/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
} catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 启发式打分
|
||||||
|
function check(el) {
|
||||||
|
if (!el || el.__tvFiltered || el.__tvChecked) return;
|
||||||
|
var rect = el.getBoundingClientRect();
|
||||||
|
var w = rect.width, h = rect.height;
|
||||||
|
if (el.tagName === 'IMG' && (!el.complete || (w === 0 && h === 0))) {
|
||||||
|
if (!el.__tvWait) {
|
||||||
|
el.__tvWait = true;
|
||||||
|
el.addEventListener('load', function() { check(el); }, { once: true });
|
||||||
|
}
|
||||||
|
if (w === 0 && h === 0) return;
|
||||||
|
}
|
||||||
|
if (w > 0 && h > 0 && w < 40 && h < 40) { el.__tvChecked = true; return; } // 小图标
|
||||||
|
if (nearVideo(el)) { el.__tvChecked = true; return; }
|
||||||
|
|
||||||
|
var src = el.tagName === 'IMG' ? (el.currentSrc || el.src || el.getAttribute('data-src') || '')
|
||||||
|
: el.tagName === 'IFRAME' ? (el.src || '') : bgImage(el);
|
||||||
|
var link = el.tagName === 'A' ? el : (el.closest ? el.closest('a') : null);
|
||||||
|
var href = link ? (link.getAttribute('href') || '') : '';
|
||||||
|
var text = ((el.getAttribute('alt') || '') + ' ' + (el.getAttribute('title') || '') + ' ' +
|
||||||
|
(link ? (link.getAttribute('title') || '') + ' ' + href : '')).slice(0, 500);
|
||||||
|
|
||||||
|
var score = 0;
|
||||||
|
if (/\.gif(\?|#|$)/i.test(src)) score += 2;
|
||||||
|
if (link) {
|
||||||
|
if (isExternal(href)) score += 2;
|
||||||
|
else if (!href || /^javascript:/i.test(href) || href === '#') score += 1;
|
||||||
|
else score -= 2;
|
||||||
|
}
|
||||||
|
if (el.tagName === 'IFRAME' && isExternal(src)) score += 1;
|
||||||
|
if (w >= 200 && h > 0 && w >= h * 3) score += 1;
|
||||||
|
if (inFixedLayer(el)) score += 1;
|
||||||
|
var decodedSrc = src;
|
||||||
|
try { decodedSrc = decodeURIComponent(src); } catch (e) {}
|
||||||
|
if (BAD_WORDS.test(text) || BAD_WORDS.test(decodedSrc.slice(0, 300))) score += 3;
|
||||||
|
|
||||||
|
el.__tvChecked = true;
|
||||||
|
if (score >= 3) hide(link && link !== document.body ? link : el, 'score' + score);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 悬浮层检测:反拦截广告常把动图切成多个小块,用 base64 背景图拼接,
|
||||||
|
// 挂在 body 下、position:fixed、z-index 极高、类名随机(如 tVBxXii),网络层和 img 扫描都抓不到
|
||||||
|
var GIF_BG = /(\.gif(\?|#|["')]|$))|data:image\/gif|base64,R0lGOD/i;
|
||||||
|
var blockedClasses = {};
|
||||||
|
function rawBg(el) {
|
||||||
|
try { return window.getComputedStyle(el).backgroundImage || ''; } catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
function hasGifInside(el) {
|
||||||
|
if (GIF_BG.test(rawBg(el))) return true;
|
||||||
|
var nodes = el.querySelectorAll ? el.querySelectorAll('*') : [];
|
||||||
|
for (var i = 0; i < nodes.length && i < 40; i++) {
|
||||||
|
var n = nodes[i];
|
||||||
|
if (n.tagName === 'IMG' && /(\.gif(\?|#|$))|data:image\/gif|base64,R0lGOD/i.test(n.currentSrc || n.src || '')) return true;
|
||||||
|
if (GIF_BG.test(rawBg(n))) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function hasAnyImage(el) {
|
||||||
|
if (rawBg(el).indexOf('url(') >= 0) return true;
|
||||||
|
return !!(el.querySelector && el.querySelector('img, [style*="background"]'));
|
||||||
|
}
|
||||||
|
function blockClass(el) {
|
||||||
|
var cls = String(el.className || '').trim();
|
||||||
|
// 仅对单一、非通用的类名加 CSS 规则,防止广告脚本重新显示 / 重建同类节点
|
||||||
|
if (!cls || /\s/.test(cls) || blockedClasses[cls] || cls.length < 4) return;
|
||||||
|
if (!/^[A-Za-z][\w-]*$/.test(cls)) return;
|
||||||
|
blockedClasses[cls] = true;
|
||||||
|
var s = document.getElementById('__tv_content_filter_css');
|
||||||
|
if (s) s.appendChild(document.createTextNode('.' + cls + '{display:none !important;visibility:hidden !important;}'));
|
||||||
|
}
|
||||||
|
function checkFloating(el) {
|
||||||
|
if (!el || el.__tvFiltered || el.nodeType !== 1) return;
|
||||||
|
var tag = el.tagName;
|
||||||
|
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'LINK' || tag === 'VIDEO' || tag === 'HEAD') return;
|
||||||
|
var cs;
|
||||||
|
try { cs = window.getComputedStyle(el); } catch (e) { return; }
|
||||||
|
if (cs.display === 'none') return;
|
||||||
|
var pos = cs.position;
|
||||||
|
var z = parseInt(cs.zIndex, 10) || 0;
|
||||||
|
var floating = pos === 'fixed' || pos === 'sticky' || (pos === 'absolute' && z >= 9999);
|
||||||
|
if (!floating) return;
|
||||||
|
if (el.querySelector && el.querySelector('video')) return;
|
||||||
|
// 几乎全透明的悬浮层:看不见却会抢走点击(点击劫持),直接移出点击范围
|
||||||
|
var op = parseFloat(cs.opacity);
|
||||||
|
if (pos === 'fixed' && !isNaN(op) && op > 0 && op <= 0.05) {
|
||||||
|
hide(el, 'click-trap');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var gif = hasGifInside(el);
|
||||||
|
var topMost = z >= 2147480000 && hasAnyImage(el);
|
||||||
|
if (gif || topMost) {
|
||||||
|
hide(el, gif ? 'float-gif' : 'float-top');
|
||||||
|
blockClass(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function scanFloating() {
|
||||||
|
var roots = [document.body, document.documentElement];
|
||||||
|
for (var r = 0; r < roots.length; r++) {
|
||||||
|
var kids = roots[r] ? roots[r].children : [];
|
||||||
|
for (var i = 0; i < kids.length && i < 400; i++) checkFloating(kids[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scan() {
|
||||||
|
scanFloating();
|
||||||
|
var imgs = document.images;
|
||||||
|
for (var i = 0; i < imgs.length; i++) check(imgs[i]);
|
||||||
|
var frames = document.getElementsByTagName('iframe');
|
||||||
|
for (var j = 0; j < frames.length; j++) check(frames[j]);
|
||||||
|
// 外链上的背景图(常见于 CSS 背景动图广告)
|
||||||
|
var links = document.querySelectorAll('a[href^="http"], a[href^="//"]');
|
||||||
|
for (var k = 0; k < links.length && k < 400; k++) {
|
||||||
|
var a = links[k];
|
||||||
|
if (a.__tvChecked || a.__tvFiltered) continue;
|
||||||
|
if (isExternal(a.getAttribute('href')) && bgImage(a)) check(a);
|
||||||
|
else a.__tvChecked = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var timer = null;
|
||||||
|
function schedule() {
|
||||||
|
if (timer) return;
|
||||||
|
timer = setTimeout(function() { timer = null; scan(); }, 300);
|
||||||
|
}
|
||||||
|
window.__tvContentFilter = schedule;
|
||||||
|
// 4. 防跳转劫持:记录用户真正点击的链接(捕获阶段,早于页面脚本),原生层只放行与之匹配的跨站跳转
|
||||||
|
function recordLink(e) {
|
||||||
|
try {
|
||||||
|
var t = e.target;
|
||||||
|
var a = t && t.closest ? t.closest('a[href]') : null;
|
||||||
|
var g = window.AndroidNavGuard;
|
||||||
|
if (g && g.onUserLink) g.onUserLink(a ? a.href : '');
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
window.addEventListener('click', recordLink, true);
|
||||||
|
scan();
|
||||||
|
try {
|
||||||
|
new MutationObserver(schedule).observe(document.documentElement, { childList: true, subtree: true });
|
||||||
|
} catch (e) {}
|
||||||
|
window.addEventListener('load', schedule);
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.tv.browser.databinding.ItemContinueBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页追剧列表(继续观看):一部剧一张卡片。
|
||||||
|
* 确定键 → 继续播放;长按确定 / 菜单键 → 移除。
|
||||||
|
*/
|
||||||
|
class ContinueWatchingAdapter(
|
||||||
|
private val onPlay: (PlaybackRecord) -> Unit,
|
||||||
|
private val onRemove: (PlaybackRecord, Int) -> Unit
|
||||||
|
) : RecyclerView.Adapter<ContinueWatchingAdapter.VH>() {
|
||||||
|
|
||||||
|
private var items: List<PlaybackRecord> = emptyList()
|
||||||
|
|
||||||
|
class VH(val b: ItemContinueBinding) : RecyclerView.ViewHolder(b.root)
|
||||||
|
|
||||||
|
fun submit(list: List<PlaybackRecord>) {
|
||||||
|
items = list
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
|
||||||
|
VH(ItemContinueBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = items.size
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
|
val r = items[position]
|
||||||
|
holder.b.continueTitle.text = r.displayTitle()
|
||||||
|
holder.b.continueSubtitle.text = r.displaySubtitle()
|
||||||
|
holder.b.continueProgress.progress = r.getProgressPercent()
|
||||||
|
holder.b.root.setOnClickListener { onPlay(r) }
|
||||||
|
holder.b.continueTitle.setMarqueeEnabled(holder.b.root.isFocused)
|
||||||
|
holder.b.root.setOnFocusChangeListener { view, hasFocus ->
|
||||||
|
val scale = if (hasFocus) 1.05f else 1.0f
|
||||||
|
view.animate().scaleX(scale).scaleY(scale).setDuration(150).start()
|
||||||
|
view.elevation = if (hasFocus) 6f else 0f
|
||||||
|
// 选中时标题滚动显示,未选中时省略号
|
||||||
|
holder.b.continueTitle.setMarqueeEnabled(hasFocus)
|
||||||
|
}
|
||||||
|
holder.b.root.setOnLongClickListener {
|
||||||
|
onRemove(r, holder.bindingAdapterPosition)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
holder.b.root.setOnKeyListener { _, keyCode, event ->
|
||||||
|
if (keyCode == KeyEvent.KEYCODE_MENU && event.action == KeyEvent.ACTION_UP) {
|
||||||
|
onRemove(r, holder.bindingAdapterPosition)
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewRecycled(holder: VH) {
|
||||||
|
super.onViewRecycled(holder)
|
||||||
|
holder.b.root.scaleX = 1.0f
|
||||||
|
holder.b.root.scaleY = 1.0f
|
||||||
|
holder.b.root.elevation = 0f
|
||||||
|
holder.b.continueTitle.setMarqueeEnabled(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.tv.browser.databinding.ItemHistoryBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录列表适配器。
|
||||||
|
*
|
||||||
|
* 使用 RecyclerView 而非 ListView:ListView 的「选中项(selection)」机制与可获焦子项混用时,
|
||||||
|
* 遥控器上下键会出现需要按两次、跳项、焦点卡住等不灵敏问题;RecyclerView 走标准焦点查找,响应稳定。
|
||||||
|
*/
|
||||||
|
class HistoryAdapter(
|
||||||
|
private var list: List<HistoryItem>,
|
||||||
|
private val onOpen: (HistoryItem) -> Unit
|
||||||
|
) : RecyclerView.Adapter<HistoryAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
fun updateList(newList: List<HistoryItem>) {
|
||||||
|
list = newList
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getItem(position: Int): HistoryItem? = list.getOrNull(position)
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = list.size
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val binding = ItemHistoryBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
return ViewHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
val item = list[position]
|
||||||
|
holder.binding.historyTitle.text = item.displayTitle()
|
||||||
|
holder.binding.historyUrl.text = item.url
|
||||||
|
holder.binding.root.setOnClickListener { onOpen(item) }
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewHolder(val binding: ItemHistoryBinding) : RecyclerView.ViewHolder(binding.root)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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 androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.tv.browser.databinding.DialogHistoryBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录对话框:列表展示、点击打开、清空历史,适配 TV 焦点。
|
||||||
|
*/
|
||||||
|
class HistoryDialog : DialogFragment() {
|
||||||
|
|
||||||
|
var onOpenUrl: ((String) -> Unit)? = null
|
||||||
|
|
||||||
|
private val repo: HistoryRepository by lazy {
|
||||||
|
HistoryRepository(requireContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var binding: DialogHistoryBinding? = null
|
||||||
|
private var adapter: HistoryAdapter? = null
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
binding = DialogHistoryBinding.inflate(LayoutInflater.from(requireContext()))
|
||||||
|
val view = binding!!.root
|
||||||
|
|
||||||
|
adapter = HistoryAdapter(
|
||||||
|
list = repo.getAll(),
|
||||||
|
onOpen = { item ->
|
||||||
|
onOpenUrl?.invoke(item.url)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
binding!!.historyList.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
binding!!.historyList.adapter = adapter
|
||||||
|
binding!!.btnClose.setOnClickListener { dismiss() }
|
||||||
|
binding!!.btnClearHistory.setOnClickListener {
|
||||||
|
repo.clear()
|
||||||
|
refreshList()
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.history_cleared), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshList()
|
||||||
|
|
||||||
|
val dialog = AlertDialog.Builder(requireContext())
|
||||||
|
.setView(view)
|
||||||
|
.create()
|
||||||
|
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
|
||||||
|
dialog.setOnShowListener { focusFirstItem() }
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 弹窗显示后把焦点放到第一条记录上;列表为空时落到“关闭”按钮 */
|
||||||
|
private fun focusFirstItem() {
|
||||||
|
val b = binding ?: return
|
||||||
|
val list = b.historyList
|
||||||
|
if ((adapter?.itemCount ?: 0) == 0) {
|
||||||
|
b.btnClose.requestFocus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list.scrollToPosition(0)
|
||||||
|
list.post {
|
||||||
|
val first = list.findViewHolderForAdapterPosition(0)?.itemView
|
||||||
|
if (first == null || !first.requestFocus()) b.btnClose.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.historyList.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览历史项:URL、标题、访问时间。
|
||||||
|
*/
|
||||||
|
data class HistoryItem(
|
||||||
|
val url: String,
|
||||||
|
val title: String,
|
||||||
|
val visitedAt: Long
|
||||||
|
) {
|
||||||
|
fun displayTitle(): String = if (title.isNotBlank()) title else url
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览历史持久化,最多保留 [MAX_ENTRIES] 条。
|
||||||
|
*/
|
||||||
|
class HistoryRepository(context: Context) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val keyHistory = "history"
|
||||||
|
|
||||||
|
fun getAll(): List<HistoryItem> {
|
||||||
|
val json = prefs.getString(keyHistory, null)
|
||||||
|
if (json.isNullOrBlank()) return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
List(arr.length()) { i ->
|
||||||
|
val obj = arr.getJSONObject(i)
|
||||||
|
HistoryItem(
|
||||||
|
url = obj.getString(KEY_URL),
|
||||||
|
title = obj.optString(KEY_TITLE, ""),
|
||||||
|
visitedAt = obj.optLong(KEY_VISITED_AT, 0L)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加一条记录:同 URL 只保留最新,总数不超过 [MAX_ENTRIES] */
|
||||||
|
fun add(url: String, title: String) {
|
||||||
|
if (url.isBlank() || url.startsWith("about:") || url.startsWith("data:")) return
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
prefs.edit().remove(keyHistory).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(list: List<HistoryItem>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
list.forEach { item ->
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put(KEY_URL, item.url)
|
||||||
|
put(KEY_TITLE, item.title)
|
||||||
|
put(KEY_VISITED_AT, item.visitedAt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
prefs.edit().putString(keyHistory, arr.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "tv_browser_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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.tv.browser.databinding.ItemHomeCardBinding
|
||||||
|
|
||||||
|
/** 徽章点缀颜色(科技感常用色) */
|
||||||
|
private val BADGE_COLORS = intArrayOf(
|
||||||
|
0xFF2563EB.toInt(), // 科技蓝
|
||||||
|
0xFF7C3AED.toInt(), // 优雅紫
|
||||||
|
0xFF059669.toInt(), // 祖母绿
|
||||||
|
0xFFD97706.toInt(), // 暖琥珀
|
||||||
|
0xFFDB2777.toInt(), // 霓虹粉
|
||||||
|
0xFF0891B2.toInt(), // 湖水蓝
|
||||||
|
0xFF4F46E5.toInt(), // 靛蓝
|
||||||
|
0xFFEA580C.toInt(), // 活力橙
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页收藏卡片网格适配器:暗色大屏微质感,支持焦点放大、边框发光与徽章展示。
|
||||||
|
*/
|
||||||
|
class HomeCardAdapter(
|
||||||
|
private var list: List<Bookmark>,
|
||||||
|
private val onItemClick: (Bookmark) -> Unit,
|
||||||
|
private val onDelete: (Bookmark) -> Unit
|
||||||
|
) : RecyclerView.Adapter<HomeCardAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
/** 是否处于卡片整理(编辑)模式:true 时所有卡片显示 X 删除标记,确认键点卡片即删除 */
|
||||||
|
var isEditing: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
fun setEditing(editing: Boolean) {
|
||||||
|
if (isEditing != editing) {
|
||||||
|
isEditing = editing
|
||||||
|
// 局部刷新:保留现有 ItemView,不会像 notifyDataSetChanged 那样丢失遥控器焦点
|
||||||
|
notifyItemRangeChanged(0, itemCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateList(newList: List<Bookmark>) {
|
||||||
|
list = newList
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val binding = ItemHomeCardBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
|
return ViewHolder(binding, createNormalBg(), createFocusedBg())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNormalBg(): GradientDrawable {
|
||||||
|
return GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.RECTANGLE
|
||||||
|
cornerRadius = 16f
|
||||||
|
setColor(0xFF1E232E.toInt())
|
||||||
|
setStroke(2, 0xFF2F3746.toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createFocusedBg(): GradientDrawable {
|
||||||
|
return GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.RECTANGLE
|
||||||
|
cornerRadius = 16f
|
||||||
|
setColor(0xFF283244.toInt())
|
||||||
|
setStroke(3, 0xFF38BDF8.toInt()) // 荧光青蓝发光边框
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirmDelete(holder: ViewHolder, item: Bookmark) {
|
||||||
|
AlertDialog.Builder(holder.itemView.context)
|
||||||
|
.setMessage("确定删除此收藏吗?")
|
||||||
|
.setPositiveButton(R.string.remove) { _, _ ->
|
||||||
|
onDelete(item)
|
||||||
|
}
|
||||||
|
.setNegativeButton(R.string.close, null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据焦点状态刷新卡片外观;不再在焦点变化时切换 ✕ 的显隐,避免焦点移动过程中布局抖动 */
|
||||||
|
private fun applyFocusStyle(holder: ViewHolder, focused: Boolean, animate: Boolean) {
|
||||||
|
val v = holder.itemView
|
||||||
|
v.background = if (focused) holder.focusedBg else holder.normalBg
|
||||||
|
holder.url.setTextColor(if (focused) 0xFF38BDF8.toInt() else 0xFF94A3B8.toInt())
|
||||||
|
val scale = if (focused) 1.08f else 1.0f
|
||||||
|
val z = if (focused) 16f else 0f
|
||||||
|
if (animate) {
|
||||||
|
v.animate().scaleX(scale).scaleY(scale).translationZ(z).setDuration(120).start()
|
||||||
|
} else {
|
||||||
|
v.animate().cancel()
|
||||||
|
v.scaleX = scale
|
||||||
|
v.scaleY = scale
|
||||||
|
v.translationZ = z
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
val item = list[position]
|
||||||
|
holder.title.text = item.displayTitle()
|
||||||
|
holder.url.text = item.displayHost()
|
||||||
|
|
||||||
|
// 提取首字符作为精美圆形徽章
|
||||||
|
val initial = item.displayTitle().trim().take(1).ifBlank { "★" }
|
||||||
|
holder.badge.text = initial
|
||||||
|
holder.badge.background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(BADGE_COLORS[position % BADGE_COLORS.size])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击卡片:整理模式下删除,否则打开网址
|
||||||
|
holder.itemView.setOnClickListener {
|
||||||
|
if (isEditing) confirmDelete(holder, item) else onItemClick(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 焦点动效:获取焦点时放大 1.08 倍并提亮边框,失去焦点时平滑缩回
|
||||||
|
holder.itemView.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
|
||||||
|
applyFocusStyle(holder, hasFocus, animate = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化背景与状态
|
||||||
|
applyFocusStyle(holder, holder.itemView.hasFocus(), animate = false)
|
||||||
|
holder.delete.visibility = if (isEditing) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
// ✕ 不可获焦,仅供触摸/鼠标点击;遥控器在整理模式下直接按确认键删除
|
||||||
|
holder.delete.setOnClickListener { confirmDelete(holder, item) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewRecycled(holder: ViewHolder) {
|
||||||
|
holder.itemView.animate().cancel()
|
||||||
|
super.onViewRecycled(holder)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int = list.size
|
||||||
|
|
||||||
|
class ViewHolder(
|
||||||
|
binding: ItemHomeCardBinding,
|
||||||
|
/** 每个卡片持有自己的背景实例(Drawable 不能跨 View 共享),避免每次焦点变化都新建对象 */
|
||||||
|
val normalBg: GradientDrawable,
|
||||||
|
val focusedBg: GradientDrawable
|
||||||
|
) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
val badge: TextView = binding.homeCardBadge
|
||||||
|
val title: TextView = binding.homeCardTitle
|
||||||
|
val url: TextView = binding.homeCardUrl
|
||||||
|
val delete: TextView = binding.homeCardDelete
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.text.TextUtils
|
||||||
|
import android.widget.TextView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单行标题的"选中滚动 / 未选中省略号"切换。
|
||||||
|
*
|
||||||
|
* TextView 的跑马灯需要 singleLine + ellipsize=MARQUEE + isSelected=true,
|
||||||
|
* 但 MARQUEE 模式下未滚动时不会显示省略号,所以按焦点状态动态切换 ellipsize。
|
||||||
|
*/
|
||||||
|
fun TextView.setMarqueeEnabled(enabled: Boolean) {
|
||||||
|
if (enabled) {
|
||||||
|
if (ellipsize != TextUtils.TruncateAt.MARQUEE) ellipsize = TextUtils.TruncateAt.MARQUEE
|
||||||
|
marqueeRepeatLimit = -1 // 无限循环
|
||||||
|
isSelected = false
|
||||||
|
isSelected = true
|
||||||
|
} else {
|
||||||
|
isSelected = false
|
||||||
|
if (ellipsize != TextUtils.TruncateAt.END) ellipsize = TextUtils.TruncateAt.END
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.os.Build
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.NetworkInterface
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查网络连接状态、系统验证结果与互联网实际可达性。
|
||||||
|
*/
|
||||||
|
object NetworkChecker {
|
||||||
|
|
||||||
|
private const val INTERNET_CHECK_URL = "https://www.baidu.com"
|
||||||
|
private const val INTERNET_CHECK_TIMEOUT_MS = 8000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回链路状态文案:已连接时区分「系统已验证互联网」与「未验证」。
|
||||||
|
*/
|
||||||
|
fun getStatusMessage(context: Context): String {
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
?: return context.getString(R.string.network_disconnected)
|
||||||
|
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
val network = cm.activeNetwork ?: return context.getString(R.string.network_disconnected)
|
||||||
|
val caps = cm.getNetworkCapabilities(network) ?: return context.getString(R.string.network_disconnected)
|
||||||
|
val typeName = when {
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ->
|
||||||
|
context.getString(R.string.network_type_wifi)
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) ->
|
||||||
|
context.getString(R.string.network_type_ethernet)
|
||||||
|
else -> context.getString(R.string.network_type_unknown)
|
||||||
|
}
|
||||||
|
val validated = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||||
|
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
if (validated) {
|
||||||
|
context.getString(R.string.network_connected, typeName)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.network_connected_not_validated, typeName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val info = cm.activeNetworkInfo
|
||||||
|
if (info?.isConnected == true) {
|
||||||
|
val typeName = when (info.type) {
|
||||||
|
ConnectivityManager.TYPE_WIFI -> context.getString(R.string.network_type_wifi)
|
||||||
|
ConnectivityManager.TYPE_ETHERNET -> context.getString(R.string.network_type_ethernet)
|
||||||
|
else -> context.getString(R.string.network_type_unknown)
|
||||||
|
}
|
||||||
|
context.getString(R.string.network_connected, typeName)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.network_disconnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isConnected(context: Context): Boolean {
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
?: return false
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||||
|
val network = cm.activeNetwork ?: return false
|
||||||
|
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||||
|
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
cm.activeNetworkInfo?.isConnected == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实际尝试访问互联网(需在后台线程调用)。
|
||||||
|
* @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
|
||||||
|
}
|
||||||
|
conn.requestMethod = "HEAD"
|
||||||
|
conn.connectTimeout = INTERNET_CHECK_TIMEOUT_MS
|
||||||
|
conn.readTimeout = INTERNET_CHECK_TIMEOUT_MS
|
||||||
|
conn.instanceFollowRedirects = true
|
||||||
|
val code = conn.responseCode
|
||||||
|
conn.disconnect()
|
||||||
|
code in 200..399
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取局域网 IP 地址(优先 Wi-Fi / 有线)
|
||||||
|
*/
|
||||||
|
fun getLocalIpAddress(): String? {
|
||||||
|
try {
|
||||||
|
val interfaces = NetworkInterface.getNetworkInterfaces()?.toList() ?: return null
|
||||||
|
val sorted = interfaces.sortedWith { a, b ->
|
||||||
|
val aScore = if (a.name.startsWith("wlan") || a.name.startsWith("eth")) 0 else 1
|
||||||
|
val bScore = if (b.name.startsWith("wlan") || b.name.startsWith("eth")) 0 else 1
|
||||||
|
aScore.compareTo(bScore)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频播放记录项:用于断点续播与继续观看推荐
|
||||||
|
*/
|
||||||
|
data class PlaybackRecord(
|
||||||
|
val url: String,
|
||||||
|
val title: String,
|
||||||
|
val positionMs: Int,
|
||||||
|
val durationMs: Int,
|
||||||
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
/** 剧集分组 key(站点主域|剧名);同一部剧只保留最近一集的记录。空表示单个视频 */
|
||||||
|
val series: String = "",
|
||||||
|
/** 剧名(如「沧元图」) */
|
||||||
|
val seriesTitle: String = "",
|
||||||
|
/** 集名(如「第03集」) */
|
||||||
|
val episodeName: String = ""
|
||||||
|
) {
|
||||||
|
fun getProgressPercent(): Int {
|
||||||
|
if (durationMs <= 0) return 0
|
||||||
|
return ((positionMs.toLong() * 100) / durationMs).toInt().coerceIn(0, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun formatPosition(): String = formatMillis(positionMs)
|
||||||
|
fun formatDuration(): String = formatMillis(durationMs)
|
||||||
|
|
||||||
|
/** 首页卡片主标题:优先剧名 */
|
||||||
|
fun displayTitle(): String = seriesTitle.ifBlank { title }
|
||||||
|
|
||||||
|
/** 首页卡片副标题:集名 · 进度(极简短文案,适配单行横向排版) */
|
||||||
|
fun displaySubtitle(): String {
|
||||||
|
val ep = episodeName.ifBlank { if (seriesTitle.isNotBlank() && title != seriesTitle) title.removePrefix(seriesTitle).trim() else "" }
|
||||||
|
val progress = when {
|
||||||
|
positionMs <= 5000 -> "待播"
|
||||||
|
durationMs > 0 -> "${getProgressPercent()}%"
|
||||||
|
else -> formatPosition()
|
||||||
|
}
|
||||||
|
return if (ep.isBlank()) progress else "$ep · $progress"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatMillis(millis: Int): String {
|
||||||
|
if (millis <= 0) return "00:00"
|
||||||
|
val totalSec = millis / 1000
|
||||||
|
val sec = totalSec % 60
|
||||||
|
val min = (totalSec / 60) % 60
|
||||||
|
val hour = totalSec / 3600
|
||||||
|
return if (hour > 0) {
|
||||||
|
String.format("%02d:%02d:%02d", hour, min, sec)
|
||||||
|
} else {
|
||||||
|
String.format("%02d:%02d", min, sec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 播放历史持久化管理(SharedPreferences + JSON)
|
||||||
|
*/
|
||||||
|
class PlaybackHistoryRepository(context: Context) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
fun getAll(): List<PlaybackRecord> {
|
||||||
|
val json = prefs.getString(KEY_RECORDS, null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(json)
|
||||||
|
List(arr.length()) { i ->
|
||||||
|
val obj = arr.getJSONObject(i)
|
||||||
|
PlaybackRecord(
|
||||||
|
url = obj.optString("url", ""),
|
||||||
|
title = obj.optString("title", ""),
|
||||||
|
positionMs = obj.optInt("positionMs", 0),
|
||||||
|
durationMs = obj.optInt("durationMs", 0),
|
||||||
|
timestamp = obj.optLong("timestamp", 0),
|
||||||
|
series = obj.optString("series", ""),
|
||||||
|
seriesTitle = obj.optString("seriesTitle", ""),
|
||||||
|
episodeName = obj.optString("episodeName", "")
|
||||||
|
)
|
||||||
|
}.filter { it.url.isNotBlank() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存播放进度:若播放时间超过 5 秒且未播完,则保存或更新。
|
||||||
|
* 带 [series] 时同一部剧只保留这一条(追剧列表里一部剧一张卡片)。
|
||||||
|
*/
|
||||||
|
fun saveProgress(
|
||||||
|
url: String,
|
||||||
|
title: String,
|
||||||
|
positionMs: Int,
|
||||||
|
durationMs: Int,
|
||||||
|
series: String = "",
|
||||||
|
seriesTitle: String = "",
|
||||||
|
episodeName: String = ""
|
||||||
|
) {
|
||||||
|
if (url.isBlank() || positionMs < 5000) return
|
||||||
|
val list = getAll().toMutableList()
|
||||||
|
list.removeAll { it.url == url || (series.isNotBlank() && it.series == series) }
|
||||||
|
|
||||||
|
// 如果已经播到最后 10 秒,视为播放结束,不再存为断点
|
||||||
|
if (durationMs > 0 && positionMs >= durationMs - 10000) {
|
||||||
|
save(list)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val record = PlaybackRecord(
|
||||||
|
url = url,
|
||||||
|
title = title.ifBlank { "在线视频" },
|
||||||
|
positionMs = positionMs,
|
||||||
|
durationMs = durationMs,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
series = series,
|
||||||
|
seriesTitle = seriesTitle,
|
||||||
|
episodeName = episodeName
|
||||||
|
)
|
||||||
|
list.add(0, record)
|
||||||
|
while (list.size > MAX_RECORDS) list.removeAt(list.size - 1)
|
||||||
|
save(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一集看完后记下"下一集待看"(进度 0),追剧列表里这部剧会显示为下一集,点开从头播。
|
||||||
|
*/
|
||||||
|
fun markUpNext(url: String, title: String, series: String, seriesTitle: String, episodeName: String) {
|
||||||
|
if (url.isBlank() || series.isBlank()) return
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取最近一条未看完的播放记录 */
|
||||||
|
fun getLastRecord(): PlaybackRecord? = getRecent(1).firstOrNull()
|
||||||
|
|
||||||
|
/** 最近未看完的记录(追剧列表):有进度的,或"下一集待看"的剧集 */
|
||||||
|
fun getRecent(limit: Int): List<PlaybackRecord> =
|
||||||
|
getAll().filter { isUnfinished(it) }.take(limit)
|
||||||
|
|
||||||
|
private fun isUnfinished(r: PlaybackRecord): Boolean {
|
||||||
|
if (r.positionMs <= 5000) return r.series.isNotBlank()
|
||||||
|
return r.durationMs <= 0 || r.positionMs < r.durationMs - 10000
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据播放地址查询历史记录 */
|
||||||
|
fun getRecordForUrl(url: String): PlaybackRecord? {
|
||||||
|
if (url.isBlank()) return null
|
||||||
|
return getAll().firstOrNull { it.url == url }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(url: String) {
|
||||||
|
val list = getAll().toMutableList()
|
||||||
|
list.removeAll { it.url == url }
|
||||||
|
save(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll() {
|
||||||
|
prefs.edit().remove(KEY_RECORDS).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(list: List<PlaybackRecord>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
list.forEach { r ->
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("url", r.url)
|
||||||
|
put("title", r.title)
|
||||||
|
put("positionMs", r.positionMs)
|
||||||
|
put("durationMs", r.durationMs)
|
||||||
|
put("timestamp", r.timestamp)
|
||||||
|
if (r.series.isNotBlank()) put("series", r.series)
|
||||||
|
if (r.seriesTitle.isNotBlank()) put("seriesTitle", r.seriesTitle)
|
||||||
|
if (r.episodeName.isNotBlank()) put("episodeName", r.episodeName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.util.Log
|
||||||
|
import java.security.cert.CertPathValidatorException
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
import javax.net.ssl.TrustManager
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原生播放器的 SSL 兼容处理。
|
||||||
|
*
|
||||||
|
* 网页里 WebView 遇到证书错误会继续加载(见 onReceivedSslError),但 ExoPlayer 走的是系统 HttpURLConnection,
|
||||||
|
* 证书过期 / 旧电视缺少新根证书(如 Let's Encrypt ISRG Root X1)/ 电视时间不准时会直接报
|
||||||
|
* ERROR_CODE_IO_NETWORK_CONNECTION_FAILED。这里在检测到 SSL 类错误后开启"宽松模式",与网页行为保持一致。
|
||||||
|
*
|
||||||
|
* 注意:HttpsURLConnection 的默认工厂是进程级的,开启后本进程内的 HttpURLConnection 请求都不再校验证书
|
||||||
|
* (WebView 不受影响)。只在播放器确实遇到 SSL 错误时才开启。
|
||||||
|
*/
|
||||||
|
object PlayerSsl {
|
||||||
|
private const val TAG = "PlayerSsl"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var lenient: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** 错误链中是否包含证书 / SSL 握手问题 */
|
||||||
|
fun isSslError(error: Throwable?): Boolean {
|
||||||
|
var e = error
|
||||||
|
var depth = 0
|
||||||
|
while (e != null && depth < 10) {
|
||||||
|
if (e is SSLException || e is CertificateException || e is CertPathValidatorException) return true
|
||||||
|
val name = e.javaClass.name
|
||||||
|
if (name.contains("SSL", ignoreCase = true) || name.contains("Cert", ignoreCase = true)) return true
|
||||||
|
e = e.cause
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取错误链最底层的原因,用于界面提示与日志 */
|
||||||
|
fun rootCauseText(error: Throwable?): String {
|
||||||
|
var e = error ?: return ""
|
||||||
|
var depth = 0
|
||||||
|
while (e.cause != null && e.cause !== e && depth < 10) {
|
||||||
|
e = e.cause!!
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
val msg = e.message?.take(80).orEmpty()
|
||||||
|
return if (msg.isBlank()) e.javaClass.simpleName else "${e.javaClass.simpleName}: $msg"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开启宽松模式:信任所有证书、不校验主机名。返回 true 表示本次新开启 */
|
||||||
|
@SuppressLint("CustomX509TrustManager", "TrustAllX509TrustManager", "BadHostnameVerifier")
|
||||||
|
@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 }
|
||||||
|
lenient = true
|
||||||
|
Log.w(TAG, "lenient SSL enabled for native player")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "enableLenient failed", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.os.SystemClock
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.HttpDataSource
|
||||||
|
import androidx.media3.exoplayer.DefaultLoadControl
|
||||||
|
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||||
|
import androidx.media3.exoplayer.source.LoadEventInfo
|
||||||
|
import androidx.media3.exoplayer.source.MediaLoadData
|
||||||
|
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
|
||||||
|
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原生播放器的缓冲 / 重试参数。
|
||||||
|
*
|
||||||
|
* 默认参数是为手机准备的:缓冲上限 50 秒、读超时由调用方决定。影视站片源常见的情况是开头速度快、
|
||||||
|
* 之后被限速或偶尔卡住一个连接,这里:
|
||||||
|
* - 网速好时多缓冲一些(最多 120 秒),扛住片源中途的降速;同时按应用可用内存限制缓冲字节数,避免低内存电视频繁 GC
|
||||||
|
* - 网络类错误多重试几次、间隔更短;签名过期类(401/403/410)不空等,尽快交给上层重新解析拿新地址
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
object PlayerTuning {
|
||||||
|
|
||||||
|
const val CONNECT_TIMEOUT_MS = 8_000
|
||||||
|
/** 单个连接卡住多久算失败(默认 20 秒太久,期间缓冲会被耗光) */
|
||||||
|
const val READ_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
fun buildLoadControl(): DefaultLoadControl {
|
||||||
|
val heap = Runtime.getRuntime().maxMemory()
|
||||||
|
val targetBytes = (heap / 4).coerceIn(16L * 1024 * 1024, 96L * 1024 * 1024).toInt()
|
||||||
|
return DefaultLoadControl.Builder()
|
||||||
|
.setBufferDurationsMs(
|
||||||
|
/* minBufferMs = */ 50_000,
|
||||||
|
/* maxBufferMs = */ 120_000,
|
||||||
|
/* bufferForPlaybackMs = */ 2_500,
|
||||||
|
/* bufferForPlaybackAfterRebufferMs = */ 4_000
|
||||||
|
)
|
||||||
|
.setTargetBufferBytes(targetBytes)
|
||||||
|
.setPrioritizeTimeOverSizeThresholds(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorPolicy : DefaultLoadErrorHandlingPolicy(6) {
|
||||||
|
override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long {
|
||||||
|
val code = responseCodeOf(loadErrorInfo.exception)
|
||||||
|
if (code == 401 || code == 403 || code == 410) {
|
||||||
|
return if (loadErrorInfo.errorCount <= 1) 1_000L else C.TIME_UNSET
|
||||||
|
}
|
||||||
|
val base = super.getRetryDelayMsFor(loadErrorInfo)
|
||||||
|
if (base == C.TIME_UNSET) return base
|
||||||
|
return minOf(loadErrorInfo.errorCount * 500L, 2_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun responseCodeOf(error: Throwable?): Int? {
|
||||||
|
var e = error
|
||||||
|
var depth = 0
|
||||||
|
while (e != null && depth < 8) {
|
||||||
|
if (e is HttpDataSource.InvalidResponseCodeException) return e.responseCode
|
||||||
|
e = e.cause
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 播放诊断:按分片统计「片源实际下载速度」与「视频本身需要的码率」,记录卡顿与分片失败,
|
||||||
|
* 用来判断卡顿是片源 / 线路跟不上,还是分片地址失效。
|
||||||
|
*
|
||||||
|
* 系统流量统计(TrafficStats)在缓冲满、播放器暂停下载时会显示 0,容易误以为网速卡;
|
||||||
|
* 这里只统计真正在下载的分片:字节数 ÷ 下载耗时 = 片源能给的速度;字节数 ÷ 分片时长 = 播放需要的速度。
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class PlaybackDiagnostics : AnalyticsListener {
|
||||||
|
|
||||||
|
/** 片源分片下载速度(字节/秒,平滑后) */
|
||||||
|
@Volatile var downloadBytesPerSec: Long = 0L
|
||||||
|
private set
|
||||||
|
/** 视频需要的速度(字节/秒,平滑后) */
|
||||||
|
@Volatile var mediaBytesPerSec: Long = 0L
|
||||||
|
private set
|
||||||
|
@Volatile var rebufferCount: Int = 0
|
||||||
|
private set
|
||||||
|
@Volatile var loadErrorCount: Int = 0
|
||||||
|
private set
|
||||||
|
@Volatile var lastError: String? = null
|
||||||
|
private set
|
||||||
|
@Volatile private var lastErrorAt: Long = 0L
|
||||||
|
|
||||||
|
private var everReady = false
|
||||||
|
private var ignoreNextBuffering = false
|
||||||
|
|
||||||
|
/** 换片源时清零 */
|
||||||
|
fun reset() {
|
||||||
|
downloadBytesPerSec = 0L
|
||||||
|
mediaBytesPerSec = 0L
|
||||||
|
rebufferCount = 0
|
||||||
|
loadErrorCount = 0
|
||||||
|
lastError = null
|
||||||
|
lastErrorAt = 0L
|
||||||
|
everReady = false
|
||||||
|
ignoreNextBuffering = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadCompleted(eventTime: AnalyticsListener.EventTime, loadEventInfo: LoadEventInfo, mediaLoadData: MediaLoadData) {
|
||||||
|
if (mediaLoadData.dataType != C.DATA_TYPE_MEDIA) return
|
||||||
|
// 独立音轨分片码率很低,混进来会拉低「需要的速度」,只统计视频 / 音视频混合分片
|
||||||
|
if (mediaLoadData.trackType == C.TRACK_TYPE_AUDIO) return
|
||||||
|
val bytes = loadEventInfo.bytesLoaded
|
||||||
|
val ms = loadEventInfo.loadDurationMs
|
||||||
|
if (bytes < 32 * 1024 || ms <= 0) return
|
||||||
|
downloadBytesPerSec = smooth(downloadBytesPerSec, bytes * 1000 / ms)
|
||||||
|
val start = mediaLoadData.mediaStartTimeMs
|
||||||
|
val end = mediaLoadData.mediaEndTimeMs
|
||||||
|
if (start != C.TIME_UNSET && end != C.TIME_UNSET && end - start >= 500) {
|
||||||
|
mediaBytesPerSec = smooth(mediaBytesPerSec, bytes * 1000 / (end - start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLoadError(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
loadEventInfo: LoadEventInfo,
|
||||||
|
mediaLoadData: MediaLoadData,
|
||||||
|
error: IOException,
|
||||||
|
wasCanceled: Boolean
|
||||||
|
) {
|
||||||
|
if (wasCanceled) return
|
||||||
|
loadErrorCount++
|
||||||
|
lastError = describe(error)
|
||||||
|
lastErrorAt = SystemClock.elapsedRealtime()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) {
|
||||||
|
when (state) {
|
||||||
|
Player.STATE_READY -> {
|
||||||
|
everReady = true
|
||||||
|
ignoreNextBuffering = false
|
||||||
|
}
|
||||||
|
Player.STATE_BUFFERING -> {
|
||||||
|
if (everReady && !ignoreNextBuffering) rebufferCount++
|
||||||
|
ignoreNextBuffering = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPositionDiscontinuity(
|
||||||
|
eventTime: AnalyticsListener.EventTime,
|
||||||
|
oldPosition: Player.PositionInfo,
|
||||||
|
newPosition: Player.PositionInfo,
|
||||||
|
reason: Int
|
||||||
|
) {
|
||||||
|
// 用户拖动进度后的缓冲不算卡顿
|
||||||
|
if (reason == Player.DISCONTINUITY_REASON_SEEK) ignoreNextBuffering = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已开始播放后又进入缓冲(即「播着播着卡了」) */
|
||||||
|
fun isStallAfterPlaying(): Boolean = everReady
|
||||||
|
|
||||||
|
/** 卡顿时给用户的原因提示;判断不出来时返回 null */
|
||||||
|
fun stallHint(): String? {
|
||||||
|
val err = lastError
|
||||||
|
if (err != null && SystemClock.elapsedRealtime() - lastErrorAt < 15_000) {
|
||||||
|
return "片源分片下载失败($err),正在重试…"
|
||||||
|
}
|
||||||
|
val down = downloadBytesPerSec
|
||||||
|
val need = mediaBytesPerSec
|
||||||
|
if (down > 0 && need > 0 && down < need * 11 / 10) {
|
||||||
|
return "片源下载 ${formatSpeed(down)},低于本视频需要的 ${formatSpeed(need)}\n片源 / 线路跟不上,可换「线路」试试"
|
||||||
|
}
|
||||||
|
if (down > 0 && need > 0) {
|
||||||
|
return "缓冲中… 片源下载 ${formatSpeed(down)},视频需要 ${formatSpeed(need)}"
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OSD 一行简述:片源速度 / 需要速度 */
|
||||||
|
fun summary(): String? {
|
||||||
|
val down = downloadBytesPerSec
|
||||||
|
val need = mediaBytesPerSec
|
||||||
|
if (down <= 0) return null
|
||||||
|
return if (need > 0) "片源 ${formatSpeed(down)} / 需 ${formatSpeed(need)}" else "片源 ${formatSpeed(down)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun smooth(old: Long, sample: Long): Long =
|
||||||
|
if (old <= 0) sample else (old * 0.6 + sample * 0.4).toLong()
|
||||||
|
|
||||||
|
private fun describe(error: Throwable): String {
|
||||||
|
PlayerTuning.responseCodeOf(error)?.let { return "HTTP $it" }
|
||||||
|
var e: Throwable? = error
|
||||||
|
var depth = 0
|
||||||
|
while (e != null && depth < 8) {
|
||||||
|
when (e) {
|
||||||
|
is SocketTimeoutException -> return "连接超时"
|
||||||
|
is UnknownHostException -> return "域名解析失败"
|
||||||
|
}
|
||||||
|
e = e.cause
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
return error.javaClass.simpleName
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun formatSpeed(bytesPerSec: Long): String = when {
|
||||||
|
bytesPerSec >= 1024 * 1024 -> String.format(Locale.CHINA, "%.1f MB/s", bytesPerSec / (1024.0 * 1024.0))
|
||||||
|
bytesPerSec >= 1024 -> "${bytesPerSec / 1024} KB/s"
|
||||||
|
else -> "$bytesPerSec B/s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.datasource.ByteArrayDataSource
|
||||||
|
import androidx.media3.datasource.DataSource
|
||||||
|
import androidx.media3.datasource.DataSpec
|
||||||
|
import androidx.media3.datasource.TransferListener
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网页里已经下载过的 m3u8 清单内容缓存。
|
||||||
|
*
|
||||||
|
* 很多影视站的 m3u8 地址是「一次性」的(带时间戳/签名,请求一次后即失效,再请求会返回
|
||||||
|
* "链接失效" 之类的页面)。网页里的 hls.js 先请求过之后,原生播放器再请求就拿不到了。
|
||||||
|
* 因此由页面 Hook 把 hls.js 拿到的清单原文回传过来,原生播放器直接用这份内容,不再重复请求。
|
||||||
|
*/
|
||||||
|
object PlaylistCache {
|
||||||
|
|
||||||
|
data class Entry(val text: String, val finalUrl: String) {
|
||||||
|
/** 主清单(多码率列表) */
|
||||||
|
val isMaster: Boolean get() = text.contains("#EXT-X-STREAM-INF")
|
||||||
|
/** 点播清单(有结束标记,内容不会变化) */
|
||||||
|
val isVod: Boolean get() = text.contains("#EXT-X-ENDLIST")
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_ENTRIES = 30
|
||||||
|
private const val MAX_TEXT_LENGTH = 4 * 1024 * 1024
|
||||||
|
|
||||||
|
private val map = object : LinkedHashMap<String, Entry>(32, 0.75f, true) {
|
||||||
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Entry>?): Boolean =
|
||||||
|
size > MAX_ENTRIES
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 记录清单内容;url 为请求地址,finalUrl 为重定向后的最终地址(用于解析相对路径) */
|
||||||
|
@Synchronized
|
||||||
|
fun put(url: String, finalUrl: String?, text: String) {
|
||||||
|
if (!text.startsWith("#EXTM3U") || text.length > MAX_TEXT_LENGTH) return
|
||||||
|
val entry = Entry(text, finalUrl?.takeIf { it.startsWith("http") } ?: url)
|
||||||
|
map[url] = entry
|
||||||
|
if (entry.finalUrl != url) map[entry.finalUrl] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun contains(url: String): Boolean = map.containsKey(url)
|
||||||
|
|
||||||
|
/** 清单是否已可直接播放:子清单已缓存;主清单则至少有一个子清单也已缓存 */
|
||||||
|
@Synchronized
|
||||||
|
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)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolve(base: Uri, ref: String): String =
|
||||||
|
try {
|
||||||
|
java.net.URI(base.toString()).resolve(ref).toString()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
ref
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun get(url: String): Entry? = map[url]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 播放器取用清单:直播清单(无结束标记的子清单)只用一次,之后的刷新走网络,避免一直播放旧内容。
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun take(url: String): Entry? {
|
||||||
|
val entry = map[url] ?: return null
|
||||||
|
if (!entry.isMaster && !entry.isVod) map.remove(url)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原生播放器的数据源包装:命中 [PlaylistCache] 的清单直接从内存读取,其余(分片、密钥等)走网络。
|
||||||
|
*/
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
class PlaylistCacheDataSource(private val upstream: DataSource) : DataSource {
|
||||||
|
|
||||||
|
private var cached: ByteArrayDataSource? = null
|
||||||
|
private var cachedUri: Uri? = null
|
||||||
|
|
||||||
|
override fun addTransferListener(transferListener: TransferListener) {
|
||||||
|
upstream.addTransferListener(transferListener)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun open(dataSpec: DataSpec): Long {
|
||||||
|
val entry = PlaylistCache.take(dataSpec.uri.toString())
|
||||||
|
if (entry != null) {
|
||||||
|
val ds = ByteArrayDataSource(entry.text.toByteArray(Charsets.UTF_8))
|
||||||
|
cached = ds
|
||||||
|
cachedUri = Uri.parse(entry.finalUrl)
|
||||||
|
return ds.open(dataSpec)
|
||||||
|
}
|
||||||
|
cached = null
|
||||||
|
cachedUri = null
|
||||||
|
return upstream.open(dataSpec)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
|
||||||
|
cached?.read(buffer, offset, length) ?: upstream.read(buffer, offset, length)
|
||||||
|
|
||||||
|
override fun getUri(): Uri? = if (cached != null) cachedUri else upstream.uri
|
||||||
|
|
||||||
|
override fun getResponseHeaders(): Map<String, List<String>> =
|
||||||
|
if (cached != null) emptyMap() else upstream.responseHeaders
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
val c = cached
|
||||||
|
if (c != null) {
|
||||||
|
c.close()
|
||||||
|
cached = null
|
||||||
|
} else {
|
||||||
|
upstream.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory(private val upstreamFactory: DataSource.Factory) : DataSource.Factory {
|
||||||
|
override fun createDataSource(): DataSource =
|
||||||
|
PlaylistCacheDataSource(upstreamFactory.createDataSource())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 净化播放页(类似夸克的影视页净化):
|
||||||
|
* 1. 识别:站点适配脚本已拿到选集,且当前地址就在选集列表里(排除详情页),页面上找得到播放器
|
||||||
|
* 2. 保留:播放器节点原地不动(移动 iframe 会导致重载),只把它固定到左上方,并隐藏它到 body 路径上的所有兄弟节点
|
||||||
|
* 3. 重绘:右侧按播放源分标签页的选集按钮(默认打开正在播放的线路),播放器下方片名与「上集 / 全屏 / 加书签 / 下集 / 原网页」
|
||||||
|
*
|
||||||
|
* 全部通过 CSS 类实现,可随时还原(「原网页」按钮或关闭开关)。
|
||||||
|
* 继续使用网站自己的播放器,因此央视网等只能网页播放的加密流同样适用。
|
||||||
|
*/
|
||||||
|
object PurifyHelper {
|
||||||
|
|
||||||
|
/** 原生层回调接口名 */
|
||||||
|
const val BRIDGE_NAME = "AndroidPurify"
|
||||||
|
|
||||||
|
/** 生成注入脚本:定义 __tvPurify 并用站点适配结果(JSON)执行 */
|
||||||
|
fun buildApplyScript(siteInfoJson: String): String =
|
||||||
|
"$JS_PURIFY\n;try{window.__tvPurify($siteInfoJson);}catch(e){}"
|
||||||
|
|
||||||
|
/** 还原原网页(并在本页内不再自动净化) */
|
||||||
|
const val JS_RESTORE = "try{window.__tvPurifyRestore&&window.__tvPurifyRestore(true);}catch(e){}"
|
||||||
|
|
||||||
|
/** 网页内全屏(没有可原生播放的视频源时,由原生层回调) */
|
||||||
|
const val JS_WEB_FULLSCREEN = "try{window.__tvPurifyWebFullscreen&&window.__tvPurifyWebFullscreen();}catch(e){}"
|
||||||
|
|
||||||
|
private val JS_PURIFY = """
|
||||||
|
(function() {
|
||||||
|
if (window.__tvPurify) return;
|
||||||
|
var STYLE_ID = '__tv_purify_css', ROOT_ID = '__tv_purify_root';
|
||||||
|
var state = null;
|
||||||
|
|
||||||
|
function norm(u) {
|
||||||
|
try {
|
||||||
|
var a = new URL(u, location.href);
|
||||||
|
return (a.host + a.pathname.replace(/\/+$/, '') + a.search).toLowerCase();
|
||||||
|
} catch (e) { return ''; }
|
||||||
|
}
|
||||||
|
function rectOf(el) {
|
||||||
|
try { return el.getBoundingClientRect(); } catch (e) { return { width: 0, height: 0 }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 找播放器 ----------------
|
||||||
|
var KNOWN = ['.MacPlayer', '#MacPlayer', '#playleft', '#cms_play', '#zanpiancms_player', '.player-box .player-left',
|
||||||
|
'#player', '.dplayer', '#dplayer', '.art-video-player', '#mse', '.video-player', '.player_video', '#video'];
|
||||||
|
function isKnown(n) {
|
||||||
|
var m = n.matches || n.webkitMatchesSelector;
|
||||||
|
if (!m) return false;
|
||||||
|
for (var k = 0; k < KNOWN.length; k++) { try { if (m.call(n, KNOWN[k])) return true; } catch (e) {} }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/** 节点内含选集链接 → 是播放器 + 选集的大容器,不能整体当播放器(选集会被一起放进播放器框里,挤掉视频) */
|
||||||
|
function hasEpLink(node, epSet) {
|
||||||
|
if (!epSet) return false;
|
||||||
|
var as = node.getElementsByTagName('a');
|
||||||
|
for (var i = 0; i < as.length && i < 500; i++) {
|
||||||
|
if (as[i].getAttribute('href') && epSet[norm(as[i].href)]) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function findPlayer(epSet) {
|
||||||
|
var best = null, bestArea = 0;
|
||||||
|
var els = document.querySelectorAll('video, iframe, embed, object');
|
||||||
|
for (var i = 0; i < els.length; i++) {
|
||||||
|
var el = els[i];
|
||||||
|
if (el.closest && el.closest('#' + ROOT_ID)) continue;
|
||||||
|
var r = rectOf(el);
|
||||||
|
if (r.width < 240 || r.height < 135 || r.width < r.height) continue;
|
||||||
|
if (r.width * r.height > bestArea) { best = el; bestArea = r.width * r.height; }
|
||||||
|
}
|
||||||
|
if (!best) {
|
||||||
|
for (var k = 0; k < KNOWN.length; k++) {
|
||||||
|
var c = document.querySelector(KNOWN[k]);
|
||||||
|
if (!c) continue;
|
||||||
|
var cr = rectOf(c);
|
||||||
|
if (cr.width >= 240 && cr.height >= 135) return c;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 向上找尺寸基本一致的外层(播放器控件条 / 16:9 占位容器),保留完整播放器;
|
||||||
|
// 遇到常见 CMS 播放器容器即停,含选集链接的外层不要
|
||||||
|
var n = best, br = rectOf(best), depth = 0;
|
||||||
|
while ((n === best || !isKnown(n)) && n.parentElement && n.parentElement !== document.body && depth < 6) {
|
||||||
|
var pr = rectOf(n.parentElement);
|
||||||
|
if (pr.width <= br.width * 1.15 + 4 && pr.height <= br.height * 1.35 + 4 && !hasEpLink(n.parentElement, epSet)) { n = n.parentElement; depth++; }
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
// 常见 CMS 播放器容器包含了它(控件条可能在视频外侧)且尺寸相近时,以容器为准,避免控件被裁掉
|
||||||
|
if (best.closest) {
|
||||||
|
for (var q = 0; q < KNOWN.length; q++) {
|
||||||
|
var kc = best.closest(KNOWN[q]);
|
||||||
|
if (!kc || kc === n || kc === document.body || !kc.contains(n) || hasEpLink(kc, epSet)) continue;
|
||||||
|
var kr = rectOf(kc);
|
||||||
|
if (kr.width <= br.width * 1.3 + 8 && kr.height <= br.height * 1.6 + 60) { n = kc; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 播放器内部真正的视频 / iframe(播放器本身就是时返回自己) */
|
||||||
|
function mediaOf(p) {
|
||||||
|
var t = p.tagName;
|
||||||
|
if (t === 'VIDEO' || t === 'IFRAME' || t === 'EMBED' || t === 'OBJECT') return p;
|
||||||
|
var els = p.querySelectorAll('video, iframe, embed, object'), best = null, bestArea = 0;
|
||||||
|
for (var i = 0; i < els.length; i++) {
|
||||||
|
var r = rectOf(els[i]);
|
||||||
|
if (r.width * r.height > bestArea) { best = els[i]; bestArea = r.width * r.height; }
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 样式 ----------------
|
||||||
|
// 注意兼容电视上的旧版系统 WebView(Chromium 5x~7x):
|
||||||
|
// 不用 CSS min()/max()/clamp()(79+,不支持时整条声明失效)、不用 flex gap(84+)、不用 inset / aspect-ratio。
|
||||||
|
// 播放器与片名按钮条的精确像素位置由 layout() 按实际视口计算,下面的 vw/vh 只是兜底。
|
||||||
|
var CSS = [
|
||||||
|
// 整页用文档自身滚动(不要做内部滚动区):遥控器光标边缘滚动 / 手机遥控都是滚动 WebView 本身
|
||||||
|
'html.tvp{background:#0f1115 !important;overflow-x:hidden !important;overflow-y:auto !important;height:auto !important;}',
|
||||||
|
'html.tvp body{background:#0f1115 !important;margin:0 !important;padding:0 !important;overflow:visible !important;height:auto !important;min-height:0 !important;}',
|
||||||
|
'.tvp-hide{display:none !important;}',
|
||||||
|
'.tvp-anc{transform:none !important;-webkit-transform:none !important;filter:none !important;-webkit-filter:none !important;backdrop-filter:none !important;',
|
||||||
|
' contain:none !important;perspective:none !important;-webkit-perspective:none !important;will-change:auto !important;animation:none !important;',
|
||||||
|
' clip-path:none !important;-webkit-clip-path:none !important;-webkit-mask:none !important;mask:none !important;}',
|
||||||
|
// 播放器的外层容器塌缩为 0 高度(播放器本身已 fixed),避免在选集上方留下空白
|
||||||
|
'.tvp-anc:not(.tvp-player){height:0 !important;min-height:0 !important;padding:0 !important;margin:0 !important;border:0 !important;}',
|
||||||
|
// 不加圆角:旧版 WebView 对 video / iframe 合成层做圆角裁剪时,部分电视 GPU 会画不全
|
||||||
|
'.tvp-player{position:fixed !important;left:2vw !important;top:3vh !important;right:auto !important;bottom:auto !important;',
|
||||||
|
' width:62vw !important;height:34.875vw !important;min-width:0 !important;min-height:0 !important;max-width:none !important;max-height:none !important;',
|
||||||
|
' margin:0 !important;padding:0 !important;border:0 !important;box-sizing:border-box !important;float:none !important;',
|
||||||
|
' z-index:2147483000 !important;background:#000 !important;border-radius:0 !important;overflow:hidden !important;}',
|
||||||
|
'.tvp-player iframe,.tvp-player video,iframe.tvp-player,video.tvp-player{width:100% !important;height:100% !important;',
|
||||||
|
' min-width:0 !important;min-height:0 !important;max-width:none !important;max-height:none !important;}',
|
||||||
|
'.tvp-player iframe,.tvp-player video{left:0 !important;top:0 !important;margin:0 !important;}',
|
||||||
|
'#R{position:relative;display:block;z-index:2147482000;color:#e5e7eb;padding:3vh 2vw 3vh 66vw;min-height:100vh;box-sizing:border-box;',
|
||||||
|
' font:16px/1.45 system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;letter-spacing:normal;text-align:left;}',
|
||||||
|
'#R *{box-sizing:border-box;float:none;text-transform:none;letter-spacing:normal;}',
|
||||||
|
// 测量 fixed 定位实际参照的视口尺寸(与缩放 / 怪异模式无关)
|
||||||
|
'#R .tvp-probe{position:fixed;left:0;top:0;width:100%;height:100%;visibility:hidden;pointer-events:none;z-index:-1;}',
|
||||||
|
// 片名与按钮条固定在播放器下方;选集列在右侧随页面滚动
|
||||||
|
'#R .tvp-info{position:fixed;z-index:2147483001;left:2vw;width:62vw;top:calc(5vh + 34.875vw);}',
|
||||||
|
'#R .tvp-eps{display:block;background:#171a21;border-radius:10px;padding:14px 16px;}',
|
||||||
|
'#R h1.tvp-title{margin:0 0 12px;padding:0;font-size:22px;font-weight:700;color:#fff;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
|
||||||
|
'#R .tvp-bar{display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;}',
|
||||||
|
'#R .tvp-btn{display:-webkit-inline-box;display:-webkit-inline-flex;display:inline-flex;-webkit-align-items:center;align-items:center;',
|
||||||
|
' -webkit-justify-content:center;justify-content:center;min-width:88px;height:40px;padding:0 16px;margin:0 10px 10px 0;border:0;',
|
||||||
|
' border-radius:8px;background:#232733;color:#e5e7eb;font-size:16px;text-decoration:none;cursor:pointer;outline:none;}',
|
||||||
|
'#R .tvp-btn.disabled{opacity:.35;pointer-events:none;}',
|
||||||
|
'#R .tvp-btn:hover,#R .tvp-btn:focus,#R .tvp-ep:hover,#R .tvp-ep:focus{background:#2563eb;color:#fff;}',
|
||||||
|
'#R .tvp-src{margin:2px 0 8px;font-size:15px;color:#94a3b8;}',
|
||||||
|
'#R .tvp-src.cur{color:#38bdf8;font-weight:700;}',
|
||||||
|
// 播放源标签页:选集很长时标签条吸顶(旧版不支持 sticky 时只是不吸顶)
|
||||||
|
'#R .tvp-tabs{display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;position:-webkit-sticky;position:sticky;top:0;z-index:1;',
|
||||||
|
' margin:-14px -16px 12px;padding:12px 16px 4px;background:#171a21;border-bottom:1px solid #2a2f3a;border-radius:10px 10px 0 0;}',
|
||||||
|
'#R .tvp-tab{display:inline-block;max-width:100%;padding:7px 14px;margin:0 8px 8px 0;border-radius:8px;background:#232733;color:#94a3b8;',
|
||||||
|
' font-size:15px;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;outline:none;cursor:pointer;border:2px solid transparent;}',
|
||||||
|
'#R .tvp-tab.playing{color:#38bdf8;}',
|
||||||
|
'#R .tvp-tab.on{background:#1e3a8a;color:#fff;font-weight:700;border-color:#3b82f6;}',
|
||||||
|
'#R .tvp-tab:hover,#R .tvp-tab:focus{background:#2563eb;color:#fff;}',
|
||||||
|
'#R .tvp-cnt{display:inline-block;margin-left:6px;padding:0 6px;border-radius:9px;background:rgba(255,255,255,.12);font-size:12px;font-weight:400;line-height:18px;vertical-align:1px;}',
|
||||||
|
'#R .tvp-off{display:none !important;}',
|
||||||
|
'#R .tvp-list{display:-webkit-box;display:-webkit-flex;display:flex;-webkit-flex-wrap:wrap;flex-wrap:wrap;margin-bottom:8px;}',
|
||||||
|
'#R .tvp-ep{display:inline-block;min-width:72px;max-width:100%;padding:8px 12px;margin:0 8px 8px 0;border-radius:8px;background:#232733;color:#cbd5e1;',
|
||||||
|
' font-size:15px;text-align:center;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;outline:none;}',
|
||||||
|
'#R .tvp-ep.cur{background:#1e3a8a;color:#93c5fd;font-weight:700;}',
|
||||||
|
'@media (max-aspect-ratio:1/1){',
|
||||||
|
' .tvp-player{left:0 !important;top:0 !important;width:100vw !important;height:56.25vw !important;border-radius:0 !important;}',
|
||||||
|
' #R{padding:calc(56.25vw + 12px) 0 0 0;}',
|
||||||
|
' #R .tvp-info{position:static;padding:0 3vw 12px;width:auto;}',
|
||||||
|
' #R .tvp-eps{border-radius:0;}',
|
||||||
|
'}'
|
||||||
|
].join('\n').replace(/#R/g, '#' + ROOT_ID);
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var e = document.createElement(tag);
|
||||||
|
if (cls) e.className = cls;
|
||||||
|
if (text != null) e.textContent = text;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
function linkBtn(text, url) {
|
||||||
|
var a = el('a', 'tvp-btn' + (url ? '' : ' disabled'), text);
|
||||||
|
a.href = url || 'javascript:void(0)';
|
||||||
|
a.setAttribute('target', '_self');
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
function actionBtn(text, fn) {
|
||||||
|
var a = el('a', 'tvp-btn', text);
|
||||||
|
a.href = 'javascript:void(0)';
|
||||||
|
a.addEventListener('click', function(ev) { ev.preventDefault(); ev.stopPropagation(); fn(); }, true);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 隐藏无关内容 ----------------
|
||||||
|
function shouldKeep(n) {
|
||||||
|
if (!n || n.nodeType !== 1) return true;
|
||||||
|
if (n.id === ROOT_ID || n.id === STYLE_ID) return true;
|
||||||
|
var t = n.tagName;
|
||||||
|
return t === 'SCRIPT' || t === 'STYLE' || t === 'LINK' || t === 'META' || t === 'HEAD' || t === 'TITLE' || t === 'NOSCRIPT';
|
||||||
|
}
|
||||||
|
function hideSiblings(node) {
|
||||||
|
var p = node.parentElement;
|
||||||
|
if (!p) return;
|
||||||
|
for (var c = p.firstElementChild; c; c = c.nextElementSibling) {
|
||||||
|
if (c !== node && !shouldKeep(c) && !c.classList.contains('tvp-anc')) c.classList.add('tvp-hide');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPanel(info, player) {
|
||||||
|
var cur = norm(location.href);
|
||||||
|
var groups = (info.sources && info.sources.length > 1)
|
||||||
|
? info.sources
|
||||||
|
: [{ name: '选集', episodes: info.episodes || [] }];
|
||||||
|
// 当前所在线路与集数
|
||||||
|
var gi = -1, ei = -1;
|
||||||
|
for (var g = 0; g < groups.length && gi < 0; g++) {
|
||||||
|
var eps = groups[g].episodes || [];
|
||||||
|
for (var e = 0; e < eps.length; e++) { if (norm(eps[e].url) === cur) { gi = g; ei = e; break; } }
|
||||||
|
}
|
||||||
|
if (gi < 0) return null; // 当前地址不在选集里:不是播放页(如详情页)
|
||||||
|
var list = groups[gi].episodes;
|
||||||
|
var prev = ei > 0 ? list[ei - 1].url : '';
|
||||||
|
var next = info.next || (ei + 1 < list.length ? list[ei + 1].url : '');
|
||||||
|
|
||||||
|
var root = el('div');
|
||||||
|
root.id = ROOT_ID;
|
||||||
|
root.appendChild(el('div', 'tvp-probe'));
|
||||||
|
var infoBox = el('div', 'tvp-info');
|
||||||
|
var title = (info.title || document.title || '').trim();
|
||||||
|
var epName = (list[ei] && list[ei].name) || info.episodeName || '';
|
||||||
|
infoBox.appendChild(el('h1', 'tvp-title', title + (epName && title.indexOf(epName) < 0 ? ' ' + epName : '')));
|
||||||
|
var bar = el('div', 'tvp-bar');
|
||||||
|
bar.appendChild(linkBtn('⏮ 上集', prev));
|
||||||
|
bar.appendChild(actionBtn('⛶ 全屏', function() {
|
||||||
|
var b = window.${BRIDGE_NAME};
|
||||||
|
if (b && b.onFullscreen) b.onFullscreen(); else window.__tvPurifyWebFullscreen();
|
||||||
|
}));
|
||||||
|
bar.appendChild(actionBtn('☆ 加书签', function() {
|
||||||
|
var b = window.${BRIDGE_NAME};
|
||||||
|
if (b && b.onBookmark) b.onBookmark();
|
||||||
|
}));
|
||||||
|
bar.appendChild(linkBtn('下集 ⏭', next));
|
||||||
|
bar.appendChild(actionBtn('↩ 原网页', function() { window.__tvPurifyRestore(true); }));
|
||||||
|
infoBox.appendChild(bar);
|
||||||
|
root.appendChild(infoBox);
|
||||||
|
|
||||||
|
var epsBox = el('div', 'tvp-eps');
|
||||||
|
// 按播放源分标签页:默认打开正在播放的线路,点标签切换(只是切换显示,不跳转)
|
||||||
|
var tabs = [], panes = [];
|
||||||
|
function showTab(idx) {
|
||||||
|
for (var t = 0; t < tabs.length; t++) {
|
||||||
|
var on = tabs[t].idx === idx;
|
||||||
|
tabs[t].btn.classList[on ? 'add' : 'remove']('on');
|
||||||
|
panes[t].classList[on ? 'remove' : 'add']('tvp-off');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var tabBar = el('div', 'tvp-tabs');
|
||||||
|
for (var gg = 0; gg < groups.length; gg++) {
|
||||||
|
var grp = groups[gg];
|
||||||
|
if (!grp.episodes || !grp.episodes.length) continue;
|
||||||
|
var name = grp.name || ('线路' + (gg + 1));
|
||||||
|
var tab = actionBtn((gg === gi ? '▶ ' : '') + name, (function(idx) { return function() { showTab(idx); }; })(gg));
|
||||||
|
tab.className = 'tvp-tab' + (gg === gi ? ' playing' : '');
|
||||||
|
tab.appendChild(el('span', 'tvp-cnt', String(grp.episodes.length)));
|
||||||
|
tab.title = name + '(' + grp.episodes.length + ' 集)';
|
||||||
|
tabBar.appendChild(tab);
|
||||||
|
var box = el('div', 'tvp-list');
|
||||||
|
for (var k = 0; k < grp.episodes.length; k++) {
|
||||||
|
var ep = grp.episodes[k];
|
||||||
|
var a = el('a', 'tvp-ep' + (gg === gi && k === ei ? ' cur' : ''), ep.name || ('第' + (k + 1) + '集'));
|
||||||
|
a.href = ep.url;
|
||||||
|
a.title = ep.name || '';
|
||||||
|
a.setAttribute('target', '_self');
|
||||||
|
box.appendChild(a);
|
||||||
|
}
|
||||||
|
tabs.push({ idx: gg, btn: tab });
|
||||||
|
panes.push(box);
|
||||||
|
}
|
||||||
|
if (tabs.length > 1) epsBox.appendChild(tabBar);
|
||||||
|
else epsBox.appendChild(el('div', 'tvp-src cur', (tabs.length && groups.length > 1 ? (groups[gi].name || '选集') : '选集') + ' · 播放中'));
|
||||||
|
for (var pi = 0; pi < panes.length; pi++) epsBox.appendChild(panes[pi]);
|
||||||
|
showTab(gi);
|
||||||
|
root.appendChild(epsBox);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按实际视口计算播放器与片名按钮条的像素位置(替代 CSS min(),旧版 WebView 不支持)。
|
||||||
|
* 视口尺寸取自 fixed 定位的探针元素,与页面缩放 / 怪异模式无关;工具栏显隐导致 WebView 变高变矮时由 resize 重新计算。
|
||||||
|
*/
|
||||||
|
function layout() {
|
||||||
|
if (!state) return;
|
||||||
|
var vr = rectOf(state.probe);
|
||||||
|
var W = Math.round(vr.width), H = Math.round(vr.height);
|
||||||
|
if (!W || !H) return;
|
||||||
|
var P = 'html.tvp .tvp-player', R = 'html.tvp #' + ROOT_ID, css;
|
||||||
|
if (W <= H) {
|
||||||
|
var ph0 = Math.round(W * 9 / 16);
|
||||||
|
css = P + '{left:0 !important;top:0 !important;width:' + W + 'px !important;height:' + ph0 + 'px !important;}\n' +
|
||||||
|
R + '{padding:' + (ph0 + 12) + 'px 0 0 0;}\n' +
|
||||||
|
R + ' .tvp-info{position:static;width:auto;padding:0 3vw 12px;}';
|
||||||
|
} else {
|
||||||
|
var side = Math.round(W * 0.02), top = Math.round(H * 0.03), gap = Math.max(8, Math.round(H * 0.02));
|
||||||
|
var pw = Math.round(W * 0.62);
|
||||||
|
var infoH = (state.info && state.info.offsetHeight) || 90;
|
||||||
|
// 16:9,但播放器下方至少给片名与按钮条留出位置(WebView 较矮时缩小高度,网页播放器自身会留黑边)
|
||||||
|
var ph = Math.min(Math.round(pw * 9 / 16), H - top * 2 - gap - infoH);
|
||||||
|
ph = Math.max(ph, Math.round(H * 0.35));
|
||||||
|
css = P + '{left:' + side + 'px !important;top:' + top + 'px !important;width:' + pw + 'px !important;height:' + ph + 'px !important;}\n' +
|
||||||
|
R + '{padding:' + top + 'px ' + side + 'px ' + top + 'px ' + (side * 2 + pw) + 'px;}\n' +
|
||||||
|
R + ' .tvp-info{position:fixed;left:' + side + 'px;width:' + pw + 'px;top:' + (top + ph + gap) + 'px;}';
|
||||||
|
}
|
||||||
|
if (state.dyn.textContent !== css + state.fillCss) state.dyn.textContent = css + state.fillCss;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(x) {
|
||||||
|
x = Math.round(x * 10000) / 100;
|
||||||
|
return (x > 98.5 && x < 101.5) ? 100 : Math.max(0, Math.min(100, x));
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 播放器内部从视频 / iframe 到播放器之间的每层包裹,按改样式前的原始比例换算成百分比宽高。
|
||||||
|
* 网站常按原尺寸写死 px 宽高 / max-width / padding-top 比例占位,播放器框变小后这些层不跟着缩,
|
||||||
|
* 视频与控件条会被裁掉一部分;换成百分比后整个播放器按比例缩放。
|
||||||
|
*/
|
||||||
|
function measureFills(player, media) {
|
||||||
|
var rules = [];
|
||||||
|
if (!media || media === player) return '';
|
||||||
|
for (var f = media.parentElement, idx = 0; f && f !== player; f = f.parentElement, idx++) {
|
||||||
|
var pos = '';
|
||||||
|
try { pos = window.getComputedStyle(f).position; } catch (e) {}
|
||||||
|
var ref = (pos === 'absolute' || pos === 'fixed') ? f.offsetParent : f.parentElement;
|
||||||
|
if (!ref || (ref !== player && !player.contains(ref))) break;
|
||||||
|
var fr = rectOf(f), rr = rectOf(ref);
|
||||||
|
if (!fr.width || !fr.height || !rr.width || !rr.height) break;
|
||||||
|
f.setAttribute('data-tvp-fill', String(idx));
|
||||||
|
// 同层还有外置控件条 / 标题条(一般几十 px):这些条保持原高度,视频区占剩余部分;否则按比例
|
||||||
|
var dh = Math.round(rr.height - fr.height);
|
||||||
|
var h = (pos !== 'absolute' && dh > 0 && dh <= 120) ? 'calc(100% - ' + dh + 'px)' : pct(fr.height / rr.height) + '%';
|
||||||
|
var r = '.tvp-player [data-tvp-fill="' + idx + '"]{width:' + pct(fr.width / rr.width) + '% !important;height:' + h + ' !important;' +
|
||||||
|
'min-width:0 !important;min-height:0 !important;max-width:none !important;max-height:none !important;' +
|
||||||
|
'margin:0 !important;padding:0 !important;box-sizing:border-box !important;float:none !important;';
|
||||||
|
if (pos === 'absolute') {
|
||||||
|
r += 'left:' + pct((fr.left - rr.left) / rr.width) + '% !important;top:' + pct((fr.top - rr.top) / rr.height) + '% !important;right:auto !important;bottom:auto !important;';
|
||||||
|
} else if (pos === 'relative' || pos === 'sticky') {
|
||||||
|
r += 'left:0 !important;top:0 !important;';
|
||||||
|
}
|
||||||
|
rules.push(r + '}');
|
||||||
|
}
|
||||||
|
return rules.length ? '\n' + rules.join('\n') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function episodeSet(info) {
|
||||||
|
var set = {}, groups = (info.sources && info.sources.length) ? info.sources : [{ episodes: info.episodes || [] }];
|
||||||
|
for (var g = 0; g < groups.length; g++) {
|
||||||
|
var eps = groups[g].episodes || [];
|
||||||
|
for (var e = 0; e < eps.length; e++) { var k = norm(eps[e].url); if (k) set[k] = true; }
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(info) {
|
||||||
|
if (state || window.__tvPurifyDisabled) return true;
|
||||||
|
var player = findPlayer(episodeSet(info));
|
||||||
|
if (!player) return false;
|
||||||
|
var root = buildPanel(info, player);
|
||||||
|
if (!root) return true; // 不是播放页,不再重试
|
||||||
|
|
||||||
|
// 改样式前按原尺寸算好内部包裹层的比例
|
||||||
|
var fillCss = measureFills(player, mediaOf(player));
|
||||||
|
|
||||||
|
var style = document.getElementById(STYLE_ID);
|
||||||
|
if (!style) {
|
||||||
|
style = el('style');
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent = CSS;
|
||||||
|
(document.head || document.documentElement).appendChild(style);
|
||||||
|
}
|
||||||
|
var dyn = el('style');
|
||||||
|
dyn.id = STYLE_ID + '_dyn';
|
||||||
|
(document.head || document.documentElement).appendChild(dyn);
|
||||||
|
document.documentElement.classList.add('tvp');
|
||||||
|
player.classList.add('tvp-player');
|
||||||
|
var chain = [];
|
||||||
|
for (var n = player; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
|
||||||
|
n.classList.add('tvp-anc');
|
||||||
|
chain.push(n);
|
||||||
|
}
|
||||||
|
for (var i = 0; i < chain.length; i++) hideSiblings(chain[i]);
|
||||||
|
document.body.appendChild(root);
|
||||||
|
|
||||||
|
// 之后插入的广告 / 弹窗节点(挂在 body 或播放器的祖先下)也一并隐藏
|
||||||
|
var obs = null;
|
||||||
|
try {
|
||||||
|
obs = new MutationObserver(function(list) {
|
||||||
|
for (var m = 0; m < list.length; m++) {
|
||||||
|
var added = list[m].addedNodes;
|
||||||
|
for (var j = 0; j < added.length; j++) {
|
||||||
|
var a = added[j];
|
||||||
|
if (a.nodeType === 1 && !shouldKeep(a) && !a.classList.contains('tvp-anc')) a.classList.add('tvp-hide');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
obs.observe(document.body, { childList: true });
|
||||||
|
for (var c = 0; c < chain.length; c++) {
|
||||||
|
if (chain[c].parentElement && chain[c].parentElement !== document.body) obs.observe(chain[c].parentElement, { childList: true });
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
state = { player: player, root: root, style: style, dyn: dyn, obs: obs, fillCss: fillCss,
|
||||||
|
probe: root.querySelector('.tvp-probe'), info: root.querySelector('.tvp-info'), onResize: null };
|
||||||
|
layout();
|
||||||
|
state.onResize = function() { layout(); };
|
||||||
|
try { window.addEventListener('resize', state.onResize); } catch (e) {}
|
||||||
|
// 字体 / 网页播放器尺寸稍后才稳定,补算几次
|
||||||
|
setTimeout(layout, 300);
|
||||||
|
setTimeout(layout, 1200);
|
||||||
|
try { window.scrollTo(0, 0); } catch (e) {}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.__tvPurify = function(info) {
|
||||||
|
if (!info || window.__tvPurifyDisabled) return;
|
||||||
|
if (info.current && norm(info.current) !== norm(location.href)) return;
|
||||||
|
var tries = 0;
|
||||||
|
(function attempt() {
|
||||||
|
if (apply(info)) return;
|
||||||
|
if (++tries < 12) setTimeout(attempt, 500); // 播放器 iframe 常由脚本稍后插入
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__tvPurifyRestore = function(disableForPage) {
|
||||||
|
if (disableForPage) window.__tvPurifyDisabled = true;
|
||||||
|
if (!state) return;
|
||||||
|
try { if (state.obs) state.obs.disconnect(); } catch (e) {}
|
||||||
|
try { if (state.onResize) window.removeEventListener('resize', state.onResize); } catch (e) {}
|
||||||
|
var hidden = document.querySelectorAll('.tvp-hide,.tvp-anc,.tvp-player');
|
||||||
|
for (var i = 0; i < hidden.length; i++) hidden[i].classList.remove('tvp-hide', 'tvp-anc', 'tvp-player');
|
||||||
|
var fills = document.querySelectorAll('[data-tvp-fill]');
|
||||||
|
for (var fi = 0; fi < fills.length; fi++) fills[fi].removeAttribute('data-tvp-fill');
|
||||||
|
document.documentElement.classList.remove('tvp');
|
||||||
|
if (state.root && state.root.parentNode) state.root.parentNode.removeChild(state.root);
|
||||||
|
if (state.style && state.style.parentNode) state.style.parentNode.removeChild(state.style);
|
||||||
|
if (state.dyn && state.dyn.parentNode) state.dyn.parentNode.removeChild(state.dyn);
|
||||||
|
state = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.__tvPurifyWebFullscreen = function() {
|
||||||
|
var p = state && state.player;
|
||||||
|
if (!p) return;
|
||||||
|
var t = (p.tagName === 'IFRAME' || p.tagName === 'VIDEO') ? p : (p.querySelector('video') || p.querySelector('iframe') || p);
|
||||||
|
var fn = t.requestFullscreen || t.webkitRequestFullscreen;
|
||||||
|
if (fn) { try { fn.call(t); } catch (e) {} }
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机遥控回调:电视端收到网页指令后执行光标移动、点击、按键等。
|
||||||
|
*/
|
||||||
|
interface RemoteControlCallback {
|
||||||
|
/** 相对移动光标(像素) */
|
||||||
|
fun onRemoteMove(dx: Int, dy: Int)
|
||||||
|
/** 在光标位置点击(确认) */
|
||||||
|
fun onRemoteClick()
|
||||||
|
/** 双指滑动滚动页面 */
|
||||||
|
fun onRemoteScroll(dx: Int, dy: Int)
|
||||||
|
/** 遥控按键:back, home, menu, volume_up, volume_down, mute, refresh, fullscreen, dpad_* */
|
||||||
|
fun onRemoteKey(keyCode: String)
|
||||||
|
/** 文本输入:从遥控网页发送到当前焦点输入框 */
|
||||||
|
fun onRemoteText(text: String)
|
||||||
|
/** 推送网址:在电视端立即打开并加载该网址 */
|
||||||
|
fun onRemoteOpenUrl(url: String)
|
||||||
|
/** 局域网快传:手机端向电视上传文件(APK 或多媒体文件) */
|
||||||
|
fun onRemoteFileUploaded(file: java.io.File, filename: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机遥控弹窗:显示二维码与连接地址。服务由 Activity 常驻,关弹窗不关服务。
|
||||||
|
* 每 5 秒检测服务状态并更新标识;模拟器调试时使用 adb reverse tcp:8767 tcp:8766。
|
||||||
|
*/
|
||||||
|
class RemoteControlDialog : DialogFragment() {
|
||||||
|
|
||||||
|
/** 模拟器调试时,手机访问电脑的端口(adb reverse 左侧端口) */
|
||||||
|
private val emulatorHostPort = 8767
|
||||||
|
|
||||||
|
private var qrBitmap: Bitmap? = null
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var statusCheckRunnable: Runnable? = null
|
||||||
|
private val statusCheckIntervalMs = 5000L
|
||||||
|
|
||||||
|
private val browserActivity: BrowserActivity?
|
||||||
|
get() = activity as? BrowserActivity
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
val root = inflater.inflate(R.layout.dialog_remote_control, container, false)
|
||||||
|
val statusText = root.findViewById<TextView>(R.id.remoteStatus)
|
||||||
|
val statusDot = root.findViewById<View>(R.id.remoteServerStatusDot)
|
||||||
|
val statusTextRow = root.findViewById<TextView>(R.id.remoteServerStatusText)
|
||||||
|
val btnStartServer = root.findViewById<Button>(R.id.btnStartRemoteServer)
|
||||||
|
val emulatorSection = root.findViewById<LinearLayout>(R.id.remoteEmulatorSection)
|
||||||
|
val hostIpInput = root.findViewById<EditText>(R.id.remoteHostIpInput)
|
||||||
|
val qrImage = root.findViewById<ImageView>(R.id.remoteQr)
|
||||||
|
val urlText = root.findViewById<TextView>(R.id.remoteUrl)
|
||||||
|
val btnOpenOnTv = root.findViewById<Button>(R.id.btnOpenOnTv)
|
||||||
|
val btnRestartServer = root.findViewById<Button>(R.id.btnRestartRemoteServer)
|
||||||
|
val btnClose = root.findViewById<Button>(R.id.btnCloseRemote)
|
||||||
|
|
||||||
|
val onEmulator = isEmulator()
|
||||||
|
val port = browserActivity?.getRemoteControlPort() ?: 8766
|
||||||
|
|
||||||
|
// 模拟器下允许软键盘输入电脑 IP;真实 TV 下若未连接也可点击弹出输入法
|
||||||
|
if (!onEmulator) {
|
||||||
|
try {
|
||||||
|
hostIpInput.showSoftInputOnFocus = false
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateServerStatusUi(running: Boolean) {
|
||||||
|
if (running) {
|
||||||
|
statusDot.setBackgroundResource(R.drawable.remote_status_dot_running)
|
||||||
|
statusTextRow.text = getString(R.string.remote_server_status_running)
|
||||||
|
btnStartServer.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
statusDot.setBackgroundResource(R.drawable.remote_status_dot_stopped)
|
||||||
|
statusTextRow.text = getString(R.string.remote_server_status_stopped)
|
||||||
|
btnStartServer.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scheduleStatusCheck() {
|
||||||
|
statusCheckRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
statusCheckRunnable = Runnable {
|
||||||
|
if (!isAdded) return@Runnable
|
||||||
|
val running = browserActivity?.isRemoteServerRunning() == true
|
||||||
|
mainHandler.post {
|
||||||
|
if (isAdded) updateServerStatusUi(running)
|
||||||
|
scheduleStatusCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mainHandler.postDelayed(statusCheckRunnable!!, statusCheckIntervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateServerStatusUi(browserActivity?.isRemoteServerRunning() == true)
|
||||||
|
scheduleStatusCheck()
|
||||||
|
|
||||||
|
btnStartServer.setOnClickListener {
|
||||||
|
browserActivity?.ensureRemoteControlServerStarted()
|
||||||
|
updateServerStatusUi(browserActivity?.isRemoteServerRunning() == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
btnRestartServer.setOnClickListener {
|
||||||
|
browserActivity?.restartRemoteControlServer()
|
||||||
|
updateServerStatusUi(browserActivity?.isRemoteServerRunning() == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateQrAndUrl(url: String?) {
|
||||||
|
if (url != null) {
|
||||||
|
urlText.text = url
|
||||||
|
qrBitmap?.recycle()
|
||||||
|
qrBitmap = generateQrBitmap(url, 512)
|
||||||
|
qrImage.setImageBitmap(qrBitmap)
|
||||||
|
qrImage.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
urlText.text = ""
|
||||||
|
qrImage.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onEmulator) {
|
||||||
|
emulatorSection.visibility = View.VISIBLE
|
||||||
|
statusText.text = getString(R.string.remote_control_connecting) + "\n(当前为模拟器,请按下方说明操作)"
|
||||||
|
updateQrAndUrl(null)
|
||||||
|
hostIpInput.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
val ip = s?.toString()?.trim()?.takeIf { isValidIp(it) }
|
||||||
|
updateQrAndUrl(if (ip != null) "http://$ip:$emulatorHostPort/" else null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
emulatorSection.visibility = View.GONE
|
||||||
|
val ip = NetworkChecker.getLocalIpAddress()
|
||||||
|
val url = if (ip != null) "http://$ip:$port/" else null
|
||||||
|
if (url != null) {
|
||||||
|
statusText.text = getString(R.string.remote_control_connecting)
|
||||||
|
updateQrAndUrl(url)
|
||||||
|
} else {
|
||||||
|
statusText.text = getString(R.string.remote_control_connecting) + "\n请确保电视已连接 WiFi"
|
||||||
|
updateQrAndUrl(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
btnOpenOnTv.setOnClickListener {
|
||||||
|
browserActivity?.loadUrlInBrowser("http://127.0.0.1:$port/")
|
||||||
|
}
|
||||||
|
btnClose.setOnClickListener { dismiss() }
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
(activity as? BrowserActivity)?.setCursorOverlayVisible(false, this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
(activity as? BrowserActivity)?.setCursorOverlayVisible(true, this)
|
||||||
|
statusCheckRunnable?.let { mainHandler.removeCallbacks(it) }
|
||||||
|
statusCheckRunnable = null
|
||||||
|
qrBitmap?.recycle()
|
||||||
|
qrBitmap = null
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isEmulator(): Boolean {
|
||||||
|
return (Build.FINGERPRINT.startsWith("generic")
|
||||||
|
|| Build.FINGERPRINT.startsWith("unknown")
|
||||||
|
|| Build.MODEL.contains("sdk")
|
||||||
|
|| Build.MODEL.contains("Emulator")
|
||||||
|
|| Build.MODEL.contains("Android SDK")
|
||||||
|
|| Build.MANUFACTURER.contains("Genymotion")
|
||||||
|
|| (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
|
||||||
|
|| "google_sdk" == Build.PRODUCT
|
||||||
|
|| Build.HARDWARE.contains("goldfish")
|
||||||
|
|| Build.HARDWARE.contains("ranchu")
|
||||||
|
|| Build.HARDWARE.contains("vbox")
|
||||||
|
|| Build.HARDWARE.contains("vexpress")
|
||||||
|
|| Build.HARDWARE.contains("sdk"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidIp(s: String): Boolean {
|
||||||
|
if (s.isEmpty()) return false
|
||||||
|
val ipPattern = Pattern.compile(
|
||||||
|
"^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
val w = bitMatrix.width
|
||||||
|
val h = bitMatrix.height
|
||||||
|
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.RGB_565)
|
||||||
|
for (x in 0 until w) {
|
||||||
|
for (y in 0 until h) {
|
||||||
|
bitmap.setPixel(x, y, if (bitMatrix.get(x, y)) Color.BLACK else Color.WHITE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bitmap
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import fi.iki.elonen.NanoHTTPD
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.jvm.Volatile
|
||||||
|
|
||||||
|
private const val TAG = "RemoteControlServer"
|
||||||
|
private const val PORT = 8766
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地 HTTP 服务:提供遥控网页,接收 POST /cmd 的 JSON 指令并回调到主线程。
|
||||||
|
*/
|
||||||
|
class RemoteControlServer(
|
||||||
|
private val context: Context,
|
||||||
|
private val mainHandler: Handler = Handler(Looper.getMainLooper()),
|
||||||
|
private val callback: RemoteControlCallback?
|
||||||
|
) : NanoHTTPD(PORT) {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var running = false
|
||||||
|
|
||||||
|
/** 服务是否仍在运行(用于状态检测;与 NanoHTTPD.isAlive 区分,避免 override final) */
|
||||||
|
fun isServerRunning(): Boolean = running
|
||||||
|
|
||||||
|
override fun start() {
|
||||||
|
super.start()
|
||||||
|
running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stop() {
|
||||||
|
try {
|
||||||
|
super.stop()
|
||||||
|
} finally {
|
||||||
|
running = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val htmlPage: String by lazy {
|
||||||
|
try {
|
||||||
|
context.assets.open("remote.html").bufferedReader().use { it.readText() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load remote.html", e)
|
||||||
|
"<html><body>Failed to load remote page.</body></html>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val port: Int get() = PORT
|
||||||
|
|
||||||
|
private fun newCorsResponse(
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
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 == "/upload") {
|
||||||
|
return handleUpload(session)
|
||||||
|
}
|
||||||
|
return newCorsResponse(
|
||||||
|
NanoHTTPD.Response.Status.NOT_FOUND,
|
||||||
|
"text/plain",
|
||||||
|
"Not Found".toByteArray(Charsets.UTF_8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCommand(session: IHTTPSession): NanoHTTPD.Response {
|
||||||
|
var body: String? = null
|
||||||
|
try {
|
||||||
|
val files = HashMap<String, String>()
|
||||||
|
session.parseBody(files)
|
||||||
|
body = files["postData"]
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "parseBody failed", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.isNullOrBlank()) {
|
||||||
|
try {
|
||||||
|
val contentLength = session.headers["content-length"]?.toIntOrNull() ?: 0
|
||||||
|
if (contentLength > 0 && contentLength < 4096) {
|
||||||
|
val buf = ByteArray(contentLength)
|
||||||
|
var offset = 0
|
||||||
|
val stream = session.inputStream
|
||||||
|
while (offset < contentLength) {
|
||||||
|
val n = stream.read(buf, offset, contentLength - offset)
|
||||||
|
if (n <= 0) break
|
||||||
|
offset += n
|
||||||
|
}
|
||||||
|
if (offset > 0) {
|
||||||
|
body = String(buf, 0, offset, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
Log.e(TAG, "Read body fallback failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.isNullOrBlank()) {
|
||||||
|
try {
|
||||||
|
val json = JSONObject(body)
|
||||||
|
val type = json.optString("type", "")
|
||||||
|
mainHandler.post {
|
||||||
|
when (type) {
|
||||||
|
"move" -> callback?.onRemoteMove(
|
||||||
|
json.optInt("dx", 0),
|
||||||
|
json.optInt("dy", 0)
|
||||||
|
)
|
||||||
|
"click" -> callback?.onRemoteClick()
|
||||||
|
"scroll" -> callback?.onRemoteScroll(
|
||||||
|
json.optInt("dx", 0),
|
||||||
|
json.optInt("dy", 0)
|
||||||
|
)
|
||||||
|
"key" -> callback?.onRemoteKey(json.optString("keyCode", ""))
|
||||||
|
"text" -> callback?.onRemoteText(json.optString("text", ""))
|
||||||
|
"open_url" -> callback?.onRemoteOpenUrl(json.optString("url", ""))
|
||||||
|
else -> { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Parse cmd failed: $body", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newCorsResponse(
|
||||||
|
NanoHTTPD.Response.Status.OK,
|
||||||
|
"application/json",
|
||||||
|
"{}".toByteArray(Charsets.UTF_8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUpload(session: IHTTPSession): NanoHTTPD.Response {
|
||||||
|
try {
|
||||||
|
val files = HashMap<String, String>()
|
||||||
|
session.parseBody(files)
|
||||||
|
|
||||||
|
var rawFilename = session.parms["filename"] ?: ""
|
||||||
|
if (rawFilename.isBlank()) {
|
||||||
|
rawFilename = session.parameters["filename"]?.firstOrNull() ?: ""
|
||||||
|
}
|
||||||
|
if (rawFilename.isBlank()) {
|
||||||
|
rawFilename = "upload_${System.currentTimeMillis()}"
|
||||||
|
}
|
||||||
|
val cleanFilename = File(rawFilename).name.ifBlank { "uploaded_file" }
|
||||||
|
|
||||||
|
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 targetFile = File(uploadDir, cleanFilename)
|
||||||
|
tempFile.copyTo(targetFile, overwrite = true)
|
||||||
|
|
||||||
|
mainHandler.post {
|
||||||
|
callback?.onRemoteFileUploaded(targetFile, cleanFilename)
|
||||||
|
}
|
||||||
|
|
||||||
|
val respJson = JSONObject().apply {
|
||||||
|
put("status", "ok")
|
||||||
|
put("filename", cleanFilename)
|
||||||
|
put("size", targetFile.length())
|
||||||
|
}
|
||||||
|
return newCorsResponse(
|
||||||
|
NanoHTTPD.Response.Status.OK,
|
||||||
|
"application/json",
|
||||||
|
respJson.toString().toByteArray(Charsets.UTF_8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "File upload handling failed", e)
|
||||||
|
val errJson = JSONObject().apply {
|
||||||
|
put("status", "error")
|
||||||
|
put("message", e.message ?: "unknown")
|
||||||
|
}
|
||||||
|
return newCorsResponse(
|
||||||
|
NanoHTTPD.Response.Status.INTERNAL_ERROR,
|
||||||
|
"application/json",
|
||||||
|
errJson.toString().toByteArray(Charsets.UTF_8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newCorsResponse(
|
||||||
|
NanoHTTPD.Response.Status.BAD_REQUEST,
|
||||||
|
"application/json",
|
||||||
|
"{\"status\":\"error\",\"message\":\"No file received\"}".toByteArray(Charsets.UTF_8)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.webkit.WebView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TV 场景专用 WebView:
|
||||||
|
* - 不作为文本编辑器,避免系统软键盘在网页输入框获得焦点时弹出
|
||||||
|
* - 文字输入统一通过遥控网页转发
|
||||||
|
* - 暴露滚动监听,支持向下滚动自动收起工具栏,向上滚动自动唤出
|
||||||
|
*/
|
||||||
|
class RemoteWebView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : WebView(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
/** 滚动变化监听:(scrollX, scrollY, oldScrollX, oldScrollY) */
|
||||||
|
var onScrollChangeListener: ((Int, Int, Int, Int) -> Unit)? = null
|
||||||
|
|
||||||
|
override fun onCheckIsTextEditor(): Boolean {
|
||||||
|
// 告诉系统这不是文本编辑控件,避免为网页内的 input/textarea 弹出软键盘
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
|
||||||
|
super.onScrollChanged(l, t, oldl, oldt)
|
||||||
|
onScrollChangeListener?.invoke(l, t, oldl, oldt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
|
data class SearchEngine(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val searchUrl: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索引擎聚合助手:
|
||||||
|
* 1. 管理默认搜索引擎(百度、必应、搜狗、360、B站、豆瓣电影等)
|
||||||
|
* 2. 智能识别输入内容是直接网址还是搜索关键词
|
||||||
|
* 3. 构造搜索 URL
|
||||||
|
*/
|
||||||
|
object SearchEngineHelper {
|
||||||
|
|
||||||
|
private const val PREFS_NAME = "tv_browser_search_engine"
|
||||||
|
private const val KEY_DEFAULT_ENGINE = "default_engine_id"
|
||||||
|
|
||||||
|
/** 伪搜索引擎:在收藏的影视站里聚合搜片(见 VodSearchHelper),没有搜索 URL */
|
||||||
|
const val VOD_ENGINE_ID = "vod"
|
||||||
|
|
||||||
|
val ENGINES = listOf(
|
||||||
|
SearchEngine(VOD_ENGINE_ID, "影视聚合(收藏站点)", ""),
|
||||||
|
SearchEngine("baidu", "百度", "https://www.baidu.com/s?wd="),
|
||||||
|
SearchEngine("bing", "必应 Bing", "https://cn.bing.com/search?q="),
|
||||||
|
SearchEngine("sogou", "搜狗", "https://www.sogou.com/web?query="),
|
||||||
|
SearchEngine("so360", "360 搜索", "https://www.so.com/s?q="),
|
||||||
|
SearchEngine("bilibili", "B站搜索", "https://search.bilibili.com/all?keyword="),
|
||||||
|
SearchEngine("douban", "豆瓣电影", "https://search.douban.com/movie/subject_search?search_text=")
|
||||||
|
)
|
||||||
|
|
||||||
|
fun getDefaultEngine(context: Context): SearchEngine {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val id = prefs.getString(KEY_DEFAULT_ENGINE, VOD_ENGINE_ID) ?: VOD_ENGINE_ID
|
||||||
|
return ENGINES.firstOrNull { it.id == id } ?: ENGINES[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isVodDefault(context: Context): Boolean = getDefaultEngine(context).id == VOD_ENGINE_ID
|
||||||
|
|
||||||
|
/** 真正的网页搜索引擎:默认是影视聚合时用百度 */
|
||||||
|
fun getWebEngine(context: Context): SearchEngine =
|
||||||
|
getDefaultEngine(context).takeIf { it.id != VOD_ENGINE_ID } ?: ENGINES.first { it.id == "baidu" }
|
||||||
|
|
||||||
|
fun setDefaultEngine(context: Context, engineId: String) {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
prefs.edit().putString(KEY_DEFAULT_ENGINE, engineId).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断用户输入是否类似 URL 网址
|
||||||
|
*/
|
||||||
|
fun isLikelyUrl(input: String): Boolean {
|
||||||
|
val trimmed = input.trim().lowercase()
|
||||||
|
if (trimmed.startsWith("http://") || trimmed.startsWith("https://") ||
|
||||||
|
trimmed.startsWith("about:") || trimmed.startsWith("file://") ||
|
||||||
|
trimmed.startsWith("javascript:")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 如果包含空格或特殊中文字符,通常是搜索关键词
|
||||||
|
if (trimmed.contains(" ") || trimmed.contains(" ")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 匹配常见顶级域名特征
|
||||||
|
val domainPattern = Regex("^[a-zA-Z0-9-]+\\.(com|cn|net|org|tv|cc|top|xyz|vip|me|site|io|app|co|icu|info|xyz)(/.*)?$")
|
||||||
|
if (domainPattern.matches(trimmed)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val ipPattern = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?(/.*)?$")
|
||||||
|
return ipPattern.matches(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将输入转为最终可加载的目标 URL
|
||||||
|
*/
|
||||||
|
fun resolveUrl(context: Context, input: String): String {
|
||||||
|
val trimmed = input.trim()
|
||||||
|
if (trimmed.isEmpty()) return ""
|
||||||
|
return if (isLikelyUrl(trimmed)) {
|
||||||
|
if (!trimmed.startsWith("http://", ignoreCase = true) && !trimmed.startsWith("https://", ignoreCase = true)) {
|
||||||
|
"https://$trimmed"
|
||||||
|
} else {
|
||||||
|
trimmed
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val engine = getWebEngine(context)
|
||||||
|
buildSearchUrl(engine, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSearchUrl(engine: SearchEngine, keyword: String): String {
|
||||||
|
val encoded = try {
|
||||||
|
URLEncoder.encode(keyword, "UTF-8")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
keyword
|
||||||
|
}
|
||||||
|
return engine.searchUrl + encoded
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.Window
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.fragment.app.DialogFragment
|
||||||
|
import com.tv.browser.databinding.DialogSettingsBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 电视大屏专属系统设置中心:
|
||||||
|
* 采用左侧分类导航 + 右侧卡片交互的双栏布局,专为 TV 遥控器 D-Pad 导航优化。
|
||||||
|
*/
|
||||||
|
class SettingsDialog : DialogFragment() {
|
||||||
|
|
||||||
|
private var binding: DialogSettingsBinding? = null
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_TAB = "initial_tab"
|
||||||
|
const val TAB_PLAYBACK = 0
|
||||||
|
const val TAB_BROWSER = 1
|
||||||
|
const val TAB_SAFETY = 2
|
||||||
|
const val TAB_STORAGE = 3
|
||||||
|
const val TAB_ABOUT = 4
|
||||||
|
|
||||||
|
/** @param initialTab 打开时选中的分类([TAB_PLAYBACK] … [TAB_ABOUT]) */
|
||||||
|
fun newInstance(initialTab: Int = TAB_PLAYBACK): SettingsDialog = SettingsDialog().apply {
|
||||||
|
arguments = Bundle().apply { putInt(ARG_TAB, initialTab) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
val dialog = super.onCreateDialog(savedInstanceState)
|
||||||
|
dialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
binding = DialogSettingsBinding.inflate(inflater, container, false)
|
||||||
|
return binding!!.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
super.onViewCreated(view, savedInstanceState)
|
||||||
|
setupCategoryTabs()
|
||||||
|
setupPlaybackPanel()
|
||||||
|
setupBrowserPanel()
|
||||||
|
setupSafetyPanel()
|
||||||
|
setupStoragePanel()
|
||||||
|
setupAboutPanel()
|
||||||
|
|
||||||
|
binding?.btnCloseSettings?.setOnClickListener { dismiss() }
|
||||||
|
|
||||||
|
// 选中初始分类(默认第一个)
|
||||||
|
val b = binding ?: return
|
||||||
|
val tabs = listOf(b.tabPlayback, b.tabBrowser, b.tabSafety, b.tabStorage, b.tabAbout)
|
||||||
|
val initial = (arguments?.getInt(ARG_TAB, TAB_PLAYBACK) ?: TAB_PLAYBACK).coerceIn(0, tabs.size - 1)
|
||||||
|
selectTab(initial)
|
||||||
|
tabs[initial].requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
(activity as? BrowserActivity)?.setCursorOverlayVisible(false, this)
|
||||||
|
dialog?.window?.apply {
|
||||||
|
// 设置大屏弹窗尺寸(占宽约 85%,高约 85%)
|
||||||
|
val width = (resources.displayMetrics.widthPixels * 0.88).toInt()
|
||||||
|
val height = (resources.displayMetrics.heightPixels * 0.88).toInt()
|
||||||
|
setLayout(width, height)
|
||||||
|
setBackgroundDrawableResource(android.R.color.transparent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
(activity as? BrowserActivity)?.setCursorOverlayVisible(true, this)
|
||||||
|
super.onDestroyView()
|
||||||
|
binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 1. 分类 Tab 导航与遥控器焦点联动
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private fun setupCategoryTabs() {
|
||||||
|
val b = binding ?: return
|
||||||
|
val tabs = listOf(b.tabPlayback, b.tabBrowser, b.tabSafety, b.tabStorage, b.tabAbout)
|
||||||
|
|
||||||
|
tabs.forEachIndexed { index, button ->
|
||||||
|
button.setOnClickListener {
|
||||||
|
selectTab(index)
|
||||||
|
}
|
||||||
|
button.setOnFocusChangeListener { _, hasFocus ->
|
||||||
|
if (hasFocus) {
|
||||||
|
selectTab(index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 在左侧 Tab 按右方向键,跳入右侧当前面板的首个卡片
|
||||||
|
button.setOnKeyListener { _, keyCode, event ->
|
||||||
|
if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_RIGHT) {
|
||||||
|
focusFirstCardOfCurrentPanel(index)
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectTab(index: Int) {
|
||||||
|
val b = binding ?: return
|
||||||
|
val tabs = listOf(b.tabPlayback, b.tabBrowser, b.tabSafety, b.tabStorage, b.tabAbout)
|
||||||
|
val panels = listOf(b.panelPlayback, b.panelBrowser, b.panelSafety, b.panelStorage, b.panelAbout)
|
||||||
|
|
||||||
|
tabs.forEachIndexed { i, btn ->
|
||||||
|
btn.isSelected = (i == index)
|
||||||
|
}
|
||||||
|
panels.forEachIndexed { i, panel ->
|
||||||
|
panel.visibility = if (i == index) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun focusFirstCardOfCurrentPanel(tabIndex: Int) {
|
||||||
|
val b = binding ?: return
|
||||||
|
when (tabIndex) {
|
||||||
|
0 -> b.cardAutoTakeover.requestFocus()
|
||||||
|
1 -> b.cardSearchEngine.requestFocus()
|
||||||
|
2 -> b.cardAdBlock.requestFocus()
|
||||||
|
3 -> b.cardClearCache.requestFocus()
|
||||||
|
4 -> b.cardAboutVersion.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 给右侧卡片配置按左方向键返回对应 Tab 的通用监听 */
|
||||||
|
private fun bindCardLeftKeyToTab(card: View, tabIndex: Int) {
|
||||||
|
card.setOnKeyListener { _, keyCode, event ->
|
||||||
|
if (event.action == KeyEvent.ACTION_DOWN && keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
|
||||||
|
val b = binding ?: return@setOnKeyListener false
|
||||||
|
val tabs = listOf(b.tabPlayback, b.tabBrowser, b.tabSafety, b.tabStorage, b.tabAbout)
|
||||||
|
tabs.getOrNull(tabIndex)?.requestFocus()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 2. 播放与解码面板
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private fun setupPlaybackPanel() {
|
||||||
|
val b = binding ?: return
|
||||||
|
val ctx = requireContext()
|
||||||
|
|
||||||
|
bindCardLeftKeyToTab(b.cardAutoTakeover, 0)
|
||||||
|
bindCardLeftKeyToTab(b.cardDecoderMode, 0)
|
||||||
|
bindCardLeftKeyToTab(b.cardPreloadNext, 0)
|
||||||
|
bindCardLeftKeyToTab(b.cardPurifyPage, 0)
|
||||||
|
bindCardLeftKeyToTab(b.cardClearSkips, 0)
|
||||||
|
|
||||||
|
// 刷新初始状态
|
||||||
|
refreshPlaybackStatus()
|
||||||
|
|
||||||
|
// 1) 自动接管播放开关
|
||||||
|
b.cardAutoTakeover.setOnClickListener {
|
||||||
|
(activity as? BrowserActivity)?.toggleAutoTakeover()
|
||||||
|
refreshPlaybackStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 解码兼容模式切换
|
||||||
|
b.cardDecoderMode.setOnClickListener {
|
||||||
|
val modes = arrayOf(
|
||||||
|
"自动(默认推荐 · 硬件优先,失败回退)",
|
||||||
|
"强制硬解(GPU加速 · 省电高效)",
|
||||||
|
"强制软解(CPU解码 · 解决花屏/黑屏/绿屏)"
|
||||||
|
)
|
||||||
|
val currentMode = AppSettings.getDecoderMode(ctx)
|
||||||
|
AlertDialog.Builder(ctx)
|
||||||
|
.setTitle("选择视频解码模式")
|
||||||
|
.setSingleChoiceItems(modes, currentMode) { dialog, which ->
|
||||||
|
AppSettings.setDecoderMode(ctx, which)
|
||||||
|
refreshPlaybackStatus()
|
||||||
|
Toast.makeText(ctx, "解码模式已设为:${AppSettings.decoderModeName(which)}", Toast.LENGTH_SHORT).show()
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 下一集预解析
|
||||||
|
b.cardPreloadNext.setOnClickListener {
|
||||||
|
val current = AppSettings.isPreloadNext(ctx)
|
||||||
|
val newState = !current
|
||||||
|
AppSettings.setPreloadNext(ctx, newState)
|
||||||
|
refreshPlaybackStatus()
|
||||||
|
Toast.makeText(
|
||||||
|
ctx,
|
||||||
|
if (newState) "已开启下一集后台预解析" else "已关闭下一集预解析",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 净化播放页
|
||||||
|
b.cardPurifyPage.setOnClickListener {
|
||||||
|
(activity as? BrowserActivity)?.togglePurifyPage()
|
||||||
|
refreshPlaybackStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) 清空片头片尾记忆
|
||||||
|
b.cardClearSkips.setOnClickListener {
|
||||||
|
AlertDialog.Builder(ctx)
|
||||||
|
.setTitle("重置片头片尾记忆")
|
||||||
|
.setMessage("确定要清除所有连续剧长按快进记住的跳过片头/片尾时长吗?")
|
||||||
|
.setPositiveButton("重置") { _, _ ->
|
||||||
|
AppSettings.clearAllSkips(ctx)
|
||||||
|
Toast.makeText(ctx, "已重置所有剧集的片头片尾跳过记忆", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshPlaybackStatus() {
|
||||||
|
val b = binding ?: return
|
||||||
|
val ctx = context ?: return
|
||||||
|
b.tvAutoTakeoverStatus.text = if (AppSettings.isAutoTakeover(ctx)) "已开启" else "已关闭"
|
||||||
|
b.tvDecoderStatus.text = AppSettings.decoderModeName(AppSettings.getDecoderMode(ctx))
|
||||||
|
b.tvPreloadStatus.text = if (AppSettings.isPreloadNext(ctx)) "已开启" else "已关闭"
|
||||||
|
b.tvPurifyStatus.text = if (AppSettings.isPurifyPage(ctx)) "已开启" else "已关闭"
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 3. 浏览与搜索面板
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private fun setupBrowserPanel() {
|
||||||
|
val b = binding ?: return
|
||||||
|
val act = activity as? BrowserActivity
|
||||||
|
|
||||||
|
bindCardLeftKeyToTab(b.cardSearchEngine, 1)
|
||||||
|
bindCardLeftKeyToTab(b.cardUserAgent, 1)
|
||||||
|
bindCardLeftKeyToTab(b.cardZoom, 1)
|
||||||
|
bindCardLeftKeyToTab(b.cardMouseMode, 1)
|
||||||
|
|
||||||
|
refreshBrowserStatus()
|
||||||
|
|
||||||
|
// 1) 搜索引擎
|
||||||
|
b.cardSearchEngine.setOnClickListener {
|
||||||
|
act?.showSearchEngineDialog { refreshBrowserStatus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) UA
|
||||||
|
b.cardUserAgent.setOnClickListener {
|
||||||
|
act?.toggleUserAgent()
|
||||||
|
refreshBrowserStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 缩放比例
|
||||||
|
b.cardZoom.setOnClickListener {
|
||||||
|
act?.showZoomDialog { refreshBrowserStatus() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) 鼠标模式
|
||||||
|
b.cardMouseMode.setOnClickListener {
|
||||||
|
act?.toggleMouseMode()
|
||||||
|
refreshBrowserStatus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshBrowserStatus() {
|
||||||
|
val b = binding ?: return
|
||||||
|
val ctx = context ?: return
|
||||||
|
val act = activity as? BrowserActivity
|
||||||
|
|
||||||
|
b.tvEngineStatus.text = SearchEngineHelper.getDefaultEngine(ctx).name
|
||||||
|
b.tvUaStatus.text = if (act?.isDesktopUa == true) "电脑桌面版" else "移动手机版"
|
||||||
|
b.tvZoomStatus.text = "${act?.currentZoomLevel ?: 100}%"
|
||||||
|
b.tvMouseModeStatus.text = if (act?.isMouseMode == true) "已开启" else "已关闭"
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 4. 广告与安全面板
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private fun setupSafetyPanel() {
|
||||||
|
val b = binding ?: return
|
||||||
|
val act = activity as? BrowserActivity
|
||||||
|
|
||||||
|
bindCardLeftKeyToTab(b.cardAdBlock, 2)
|
||||||
|
bindCardLeftKeyToTab(b.cardContentFilter, 2)
|
||||||
|
bindCardLeftKeyToTab(b.cardUserScripts, 2)
|
||||||
|
|
||||||
|
refreshSafetyStatus()
|
||||||
|
|
||||||
|
// 1) 广告拦截
|
||||||
|
b.cardAdBlock.setOnClickListener {
|
||||||
|
act?.toggleAdBlock()
|
||||||
|
refreshSafetyStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 内容净化/动图过滤
|
||||||
|
b.cardContentFilter.setOnClickListener {
|
||||||
|
act?.showContentFilterDialog()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 用户脚本
|
||||||
|
b.cardUserScripts.setOnClickListener {
|
||||||
|
act?.showUserScriptsDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshSafetyStatus() {
|
||||||
|
val b = binding ?: return
|
||||||
|
val ctx = context ?: return
|
||||||
|
|
||||||
|
b.tvAdBlockStatus.text = if (AdBlockHelper.isEnabled(ctx)) "已开启" else "已关闭"
|
||||||
|
val strong = ContentFilterHelper.isStrongFilterEnabled(ctx)
|
||||||
|
val noImage = ContentFilterHelper.isNoImageMode(ctx)
|
||||||
|
b.tvFilterStatus.text = when {
|
||||||
|
noImage -> "无图模式"
|
||||||
|
strong -> "强力过滤"
|
||||||
|
else -> "基础防护"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 5. 存储与网络面板
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private fun setupStoragePanel() {
|
||||||
|
val b = binding ?: return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val act = activity as? BrowserActivity
|
||||||
|
|
||||||
|
bindCardLeftKeyToTab(b.cardClearCache, 3)
|
||||||
|
bindCardLeftKeyToTab(b.cardClearHistory, 3)
|
||||||
|
bindCardLeftKeyToTab(b.cardNetworkDiag, 3)
|
||||||
|
|
||||||
|
// 1) 清除缓存
|
||||||
|
b.cardClearCache.setOnClickListener {
|
||||||
|
act?.clearBrowserCache(showToast = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 清空播放历史与追剧记录
|
||||||
|
b.cardClearHistory.setOnClickListener {
|
||||||
|
AlertDialog.Builder(ctx)
|
||||||
|
.setTitle("清空播放历史与追剧记录")
|
||||||
|
.setMessage("确定清空所有网页视频播放进度及继续观看卡片吗?此操作无法撤销。")
|
||||||
|
.setPositiveButton("清空") { _, _ ->
|
||||||
|
act?.playbackHistoryRepository?.clearAll()
|
||||||
|
HistoryRepository(ctx).clear()
|
||||||
|
act?.binding?.layoutContinueWatching?.visibility = View.GONE
|
||||||
|
Toast.makeText(ctx, "播放历史与追剧记录已全部清空", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 网络诊断
|
||||||
|
b.cardNetworkDiag.setOnClickListener {
|
||||||
|
act?.checkNetworkDiagnostics()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 6. 关于与按键面板
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
private fun setupAboutPanel() {
|
||||||
|
val b = binding ?: return
|
||||||
|
val act = activity as? BrowserActivity
|
||||||
|
|
||||||
|
bindCardLeftKeyToTab(b.cardAboutVersion, 4)
|
||||||
|
bindCardLeftKeyToTab(b.cardKeyGuide, 4)
|
||||||
|
bindCardLeftKeyToTab(b.cardRemoteServer, 4)
|
||||||
|
|
||||||
|
b.tvVersionDetail.text = "版本: v${BuildConfig.VERSION_NAME} (Release) · 引擎: Chromium WebView + ExoPlayer Media3"
|
||||||
|
|
||||||
|
b.cardRemoteServer.setOnClickListener {
|
||||||
|
act?.showRemoteControlDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用入口。安装全局未捕获异常处理器,在崩溃时将日志写入文件。
|
||||||
|
*/
|
||||||
|
class TVBrowserApplication : Application() {
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
installCrashHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installCrashHandler() {
|
||||||
|
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
try {
|
||||||
|
saveCrashLog(thread, throwable)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "保存崩溃日志失败", e)
|
||||||
|
}
|
||||||
|
defaultHandler?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveCrashLog(thread: Thread, throwable: Throwable) {
|
||||||
|
val dir = File(filesDir, CRASH_LOG_DIR)
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
val dateFormat = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US)
|
||||||
|
val fileName = "crash_${dateFormat.format(Date())}.txt"
|
||||||
|
val file = File(dir, fileName)
|
||||||
|
FileWriter(file, Charsets.UTF_8).use { writer ->
|
||||||
|
val pw = PrintWriter(writer)
|
||||||
|
pw.println("=== 崩溃日志 ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date())} ===")
|
||||||
|
pw.println()
|
||||||
|
pw.println("线程: ${thread.name}")
|
||||||
|
pw.println("异常: ${throwable.javaClass.name}")
|
||||||
|
pw.println("消息: ${throwable.message}")
|
||||||
|
pw.println()
|
||||||
|
pw.println("设备: ${Build.MANUFACTURER} ${Build.MODEL}")
|
||||||
|
pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
|
||||||
|
pw.println()
|
||||||
|
pw.println("--- 堆栈 ---")
|
||||||
|
throwable.printStackTrace(pw)
|
||||||
|
var cause = throwable.cause
|
||||||
|
while (cause != null) {
|
||||||
|
pw.println()
|
||||||
|
pw.println("Caused by: ${cause.javaClass.name}: ${cause.message}")
|
||||||
|
cause.printStackTrace(pw)
|
||||||
|
cause = cause.cause
|
||||||
|
}
|
||||||
|
pw.flush()
|
||||||
|
}
|
||||||
|
Log.w(TAG, "崩溃日志已保存: ${file.absolutePath}")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "TVBrowser.Crash"
|
||||||
|
/** 崩溃日志所在子目录,可通过 Context.filesDir/CRASH_LOG_DIR 访问 */
|
||||||
|
const val CRASH_LOG_DIR = "crash_logs"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.webkit.ScriptHandler
|
||||||
|
import androidx.webkit.UserAgentMetadata
|
||||||
|
import androidx.webkit.WebSettingsCompat
|
||||||
|
import androidx.webkit.WebViewCompat
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览器标识(手机版 / 电脑版)。
|
||||||
|
*
|
||||||
|
* 只换 UA 字符串对大多数站点没有效果:
|
||||||
|
* 1. 响应式站点(`<meta name="viewport" content="width=device-width">`)按视口宽度排版,与 UA 无关;
|
||||||
|
* 2. 新版 WebView 还会发送 UA Client Hints(Sec-CH-UA-Mobile / Sec-CH-UA-Platform、navigator.userAgentData),
|
||||||
|
* 不同步的话仍会报告「Android 手机」。
|
||||||
|
*
|
||||||
|
* 因此电脑版模式参照 Chrome「桌面版网站」:UA + Client Hints 一起切换,并在主页面强制使用宽视口。
|
||||||
|
*/
|
||||||
|
object UserAgentHelper {
|
||||||
|
|
||||||
|
private const val TAG = "TVBrowser.UA"
|
||||||
|
|
||||||
|
/** 电脑版强制的最小布局宽度(CSS px),大于常见桌面断点 1200 */
|
||||||
|
private const val DESKTOP_VIEWPORT_WIDTH = 1280
|
||||||
|
|
||||||
|
fun userAgentFor(desktop: Boolean): String =
|
||||||
|
if (desktop) BrowserActivity.DESKTOP_UA else BrowserActivity.MOBILE_UA
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 UA 字符串 + Client Hints(WebView 不支持 Client Hints 覆盖时只设字符串)。
|
||||||
|
* 不处理视口,主浏览器另需调用 [setDesktopViewport]。
|
||||||
|
*/
|
||||||
|
fun applyUserAgent(settings: WebSettings, desktop: Boolean) {
|
||||||
|
settings.userAgentString = userAgentFor(desktop)
|
||||||
|
applyMetadata(settings, desktop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyMetadata(settings: WebSettings, desktop: Boolean) {
|
||||||
|
val supported = try {
|
||||||
|
WebViewFeature.isFeatureSupported(WebViewFeature.USER_AGENT_METADATA)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (!supported) {
|
||||||
|
Log.d(TAG, "USER_AGENT_METADATA not supported, only UA string applied")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
WebSettingsCompat.setUserAgentMetadata(settings, buildMetadata(desktop))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w(TAG, "setUserAgentMetadata failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与 [BrowserActivity.MOBILE_UA] / [BrowserActivity.DESKTOP_UA] 保持一致的 Client Hints */
|
||||||
|
private fun buildMetadata(desktop: Boolean): UserAgentMetadata {
|
||||||
|
val ua = userAgentFor(desktop)
|
||||||
|
val major = Regex("""Chrome/(\d+)""").find(ua)?.groupValues?.get(1) ?: "120"
|
||||||
|
val full = "$major.0.0.0"
|
||||||
|
fun brand(name: String, ver: String, fullVer: String) =
|
||||||
|
UserAgentMetadata.BrandVersion.Builder()
|
||||||
|
.setBrand(name).setMajorVersion(ver).setFullVersion(fullVer).build()
|
||||||
|
val brands = listOf(
|
||||||
|
brand("Chromium", major, full),
|
||||||
|
brand("Not(A:Brand", "24", "24.0.0.0"),
|
||||||
|
brand("Google Chrome", major, full)
|
||||||
|
)
|
||||||
|
val builder = UserAgentMetadata.Builder()
|
||||||
|
.setBrandVersionList(brands)
|
||||||
|
.setFullVersion(full)
|
||||||
|
.setModel("")
|
||||||
|
return if (desktop) {
|
||||||
|
builder.setPlatform("Windows")
|
||||||
|
.setPlatformVersion("10.0.0")
|
||||||
|
.setArchitecture("x86")
|
||||||
|
.setBitness(64)
|
||||||
|
.setWow64(false)
|
||||||
|
.setMobile(false)
|
||||||
|
.build()
|
||||||
|
} else {
|
||||||
|
builder.setPlatform("Android")
|
||||||
|
.setPlatformVersion("13.0.0")
|
||||||
|
.setMobile(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 电脑版视口:安装 / 移除页面执行前脚本(对之后加载的页面生效,切换后需 reload)。
|
||||||
|
* @param current 之前安装的句柄(切回手机版时移除)
|
||||||
|
* @return 新句柄;不支持页面执行前注入或手机版时返回 null(旧版 WebView 需在页面回调里 evaluateJavascript 兜底)
|
||||||
|
*/
|
||||||
|
fun setDesktopViewport(webView: WebView, desktop: Boolean, current: ScriptHandler?): ScriptHandler? {
|
||||||
|
try {
|
||||||
|
current?.remove()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w(TAG, "remove desktop viewport script failed", e)
|
||||||
|
}
|
||||||
|
if (!desktop || !VideoSnifferHelper.supportsDocumentStartScript()) return null
|
||||||
|
return try {
|
||||||
|
WebViewCompat.addDocumentStartJavaScript(webView, JS_DESKTOP_MODE, setOf("*"))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w(TAG, "addDocumentStartJavaScript(desktop viewport) failed", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 电脑版页面脚本(所有 frame):
|
||||||
|
* - navigator.platform / maxTouchPoints 与桌面 UA 保持一致(部分站点用 JS 判断是否移动端)
|
||||||
|
* - 仅主页面:把网页的 viewport 改为固定宽度,等价于 Chrome「桌面版网站」忽略 width=device-width;
|
||||||
|
* 监听后续插入 / 修改的 viewport,页面没有 viewport 时补一个
|
||||||
|
*/
|
||||||
|
val JS_DESKTOP_MODE = """
|
||||||
|
(function() {
|
||||||
|
if (window.__tvDesktopMode) return;
|
||||||
|
window.__tvDesktopMode = true;
|
||||||
|
try { Object.defineProperty(Navigator.prototype, 'platform', { get: function() { return 'Win32'; }, configurable: true }); } catch (e) {}
|
||||||
|
try { Object.defineProperty(Navigator.prototype, 'maxTouchPoints', { get: function() { return 0; }, configurable: true }); } catch (e) {}
|
||||||
|
if (window.top !== window) return;
|
||||||
|
var width = Math.max($DESKTOP_VIEWPORT_WIDTH, (window.screen && window.screen.width) || 0);
|
||||||
|
var VP = 'width=' + width;
|
||||||
|
function isViewport(n) {
|
||||||
|
return n && n.nodeType === 1 && n.tagName === 'META' &&
|
||||||
|
(n.getAttribute('name') || '').toLowerCase() === 'viewport';
|
||||||
|
}
|
||||||
|
function fix(n) {
|
||||||
|
if (isViewport(n) && n.getAttribute('content') !== VP) n.setAttribute('content', VP);
|
||||||
|
}
|
||||||
|
function fixAll() {
|
||||||
|
var ms = document.getElementsByTagName('meta');
|
||||||
|
var found = false;
|
||||||
|
for (var i = 0; i < ms.length; i++) { if (isViewport(ms[i])) { found = true; fix(ms[i]); } }
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new MutationObserver(function(list) {
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
var r = list[i];
|
||||||
|
if (r.type === 'attributes') { fix(r.target); continue; }
|
||||||
|
var added = r.addedNodes;
|
||||||
|
for (var j = 0; j < added.length; j++) {
|
||||||
|
var n = added[j];
|
||||||
|
if (n.nodeType !== 1) continue;
|
||||||
|
if (n.tagName === 'META') fix(n);
|
||||||
|
else if (n.tagName === 'HEAD' && n.getElementsByTagName) {
|
||||||
|
var ms = n.getElementsByTagName('meta');
|
||||||
|
for (var k = 0; k < ms.length; k++) fix(ms[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).observe(document, { childList: true, subtree: true, attributes: true, attributeFilter: ['content', 'name'] });
|
||||||
|
} catch (e) {}
|
||||||
|
function ensure() {
|
||||||
|
if (fixAll()) return;
|
||||||
|
try {
|
||||||
|
var m = document.createElement('meta');
|
||||||
|
m.setAttribute('name', 'viewport');
|
||||||
|
m.setAttribute('content', VP);
|
||||||
|
(document.head || document.documentElement).appendChild(m);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
fixAll();
|
||||||
|
document.addEventListener('DOMContentLoaded', ensure);
|
||||||
|
} else {
|
||||||
|
ensure();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 油猴式用户脚本:在匹配的页面加载完成后注入自定义 JS。
|
||||||
|
* @param id 唯一标识(用于存储与删除)
|
||||||
|
* @param name 显示名称
|
||||||
|
* @param urlPattern 网址匹配:* 表示所有页面;否则为包含匹配(当前 URL 包含该字符串时注入)
|
||||||
|
* @param script 要注入的 JavaScript 代码
|
||||||
|
* @param enabled 是否启用
|
||||||
|
*/
|
||||||
|
data class UserScript(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val urlPattern: String,
|
||||||
|
val script: String,
|
||||||
|
val enabled: Boolean = true
|
||||||
|
) {
|
||||||
|
/** 当前 URL 是否匹配此脚本的 urlPattern */
|
||||||
|
fun matchesUrl(currentUrl: String): Boolean {
|
||||||
|
if (currentUrl.isBlank()) return false
|
||||||
|
val pattern = urlPattern.trim()
|
||||||
|
if (pattern == "*" || pattern.isEmpty()) return true
|
||||||
|
return currentUrl.contains(pattern, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
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.ItemUserScriptBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户脚本列表适配器:名称、匹配规则、启用开关、移除。
|
||||||
|
*/
|
||||||
|
class UserScriptAdapter(
|
||||||
|
private val context: Context,
|
||||||
|
private var list: List<UserScript>,
|
||||||
|
private val onToggleEnabled: (UserScript, Boolean) -> Unit,
|
||||||
|
private val onRemove: (UserScript) -> Unit,
|
||||||
|
private val onItemClick: (UserScript) -> Unit
|
||||||
|
) : BaseAdapter() {
|
||||||
|
|
||||||
|
fun updateList(newList: List<UserScript>) {
|
||||||
|
list = newList
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCount(): Int = list.size
|
||||||
|
override fun getItem(position: Int): UserScript = 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) {
|
||||||
|
ItemUserScriptBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||||
|
} else {
|
||||||
|
ItemUserScriptBinding.bind(convertView)
|
||||||
|
}
|
||||||
|
val item = getItem(position)
|
||||||
|
binding.scriptName.text = if (item.name.isNotBlank()) item.name else context.getString(R.string.user_script_code_hint)
|
||||||
|
binding.scriptPattern.text = item.urlPattern.ifBlank { "*" }
|
||||||
|
binding.scriptEnabled.isChecked = item.enabled
|
||||||
|
binding.scriptEnabled.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
onToggleEnabled(item, isChecked)
|
||||||
|
}
|
||||||
|
binding.root.setOnClickListener { onItemClick(item) }
|
||||||
|
binding.btnRemove.setOnClickListener { onRemove(item) }
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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。
|
||||||
|
*/
|
||||||
|
class UserScriptRepository(context: Context) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val keyScripts = "user_scripts"
|
||||||
|
|
||||||
|
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 getEnabledForUrl(url: String): List<UserScript> =
|
||||||
|
getAll().filter { it.enabled && it.script.isNotBlank() && it.matchesUrl(url) }
|
||||||
|
|
||||||
|
fun add(script: UserScript) {
|
||||||
|
val list = getAll().toMutableList()
|
||||||
|
val newId = script.id.ifBlank { UUID.randomUUID().toString() }
|
||||||
|
list.removeAll { it.id == newId }
|
||||||
|
list.add(0, script.copy(id = newId))
|
||||||
|
save(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(script: UserScript) {
|
||||||
|
val list = getAll().toMutableList()
|
||||||
|
val idx = list.indexOfFirst { it.id == script.id }
|
||||||
|
if (idx >= 0) {
|
||||||
|
list[idx] = script
|
||||||
|
save(list)
|
||||||
|
} else {
|
||||||
|
add(script)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEnabled(id: String, enabled: Boolean) {
|
||||||
|
val list = getAll().toMutableList()
|
||||||
|
val idx = list.indexOfFirst { it.id == id }
|
||||||
|
if (idx >= 0) {
|
||||||
|
list[idx] = list[idx].copy(enabled = enabled)
|
||||||
|
save(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()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "tv_browser_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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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.DialogUserScriptsBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户脚本管理对话框:列表、添加、编辑、启用/禁用、删除,适配 TV 焦点。
|
||||||
|
*/
|
||||||
|
class UserScriptsDialog : DialogFragment() {
|
||||||
|
|
||||||
|
/** 弹窗关闭时回调 */
|
||||||
|
var onDismissCallback: (() -> Unit)? = null
|
||||||
|
|
||||||
|
private val repo: UserScriptRepository by lazy {
|
||||||
|
UserScriptRepository(requireContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var binding: DialogUserScriptsBinding? = null
|
||||||
|
private var adapter: UserScriptAdapter? = null
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
binding = DialogUserScriptsBinding.inflate(LayoutInflater.from(requireContext()))
|
||||||
|
val view = binding!!.root
|
||||||
|
|
||||||
|
adapter = UserScriptAdapter(
|
||||||
|
context = requireContext(),
|
||||||
|
list = repo.getAll(),
|
||||||
|
onToggleEnabled = { script, enabled ->
|
||||||
|
repo.setEnabled(script.id, enabled)
|
||||||
|
refreshList()
|
||||||
|
},
|
||||||
|
onRemove = { script ->
|
||||||
|
repo.remove(script.id)
|
||||||
|
refreshList()
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.bookmark_removed), Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
onItemClick = { script ->
|
||||||
|
showAddOrEditDialog(script)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
binding!!.scriptList.adapter = adapter
|
||||||
|
binding!!.btnAdd.setOnClickListener { showAddOrEditDialog(null) }
|
||||||
|
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?.scriptList?.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 showAddOrEditDialog(existing: UserScript?) {
|
||||||
|
val addBinding = com.tv.browser.databinding.DialogAddUserScriptBinding.inflate(LayoutInflater.from(requireContext()))
|
||||||
|
addBinding.scriptName.setText(existing?.name ?: "")
|
||||||
|
addBinding.scriptPattern.setText(existing?.urlPattern?.takeIf { it != "*" } ?: "")
|
||||||
|
addBinding.scriptCode.setText(existing?.script ?: "")
|
||||||
|
|
||||||
|
val addDialog = AlertDialog.Builder(requireContext())
|
||||||
|
.setView(addBinding.root)
|
||||||
|
.create()
|
||||||
|
addDialog.requestWindowFeature(Window.FEATURE_NO_TITLE)
|
||||||
|
addBinding.btnSave.setOnClickListener {
|
||||||
|
val name = addBinding.scriptName.text.toString().trim()
|
||||||
|
val pattern = addBinding.scriptPattern.text.toString().trim().ifBlank { "*" }
|
||||||
|
val code = addBinding.scriptCode.text.toString().trim()
|
||||||
|
if (code.isBlank()) {
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.user_script_code_hint), Toast.LENGTH_SHORT).show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
val script = UserScript(
|
||||||
|
id = existing?.id ?: java.util.UUID.randomUUID().toString(),
|
||||||
|
name = name.ifBlank { "脚本" },
|
||||||
|
urlPattern = pattern,
|
||||||
|
script = code,
|
||||||
|
enabled = existing?.enabled ?: true
|
||||||
|
)
|
||||||
|
repo.add(script)
|
||||||
|
addDialog.dismiss()
|
||||||
|
refreshList()
|
||||||
|
Toast.makeText(requireContext(), getString(R.string.user_script_saved), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
addBinding.btnCancel.setOnClickListener { addDialog.dismiss() }
|
||||||
|
addDialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshList() {
|
||||||
|
val list = repo.getAll()
|
||||||
|
adapter?.updateList(list)
|
||||||
|
binding?.let { b ->
|
||||||
|
b.scriptList.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于在 WebView 中检测和控制 HTML5 <video> 的 JavaScript 片段。
|
||||||
|
* 在主文档及同源 iframe 中递归查找 video(含 Artplayer 等使用的 video.art-video)。
|
||||||
|
*/
|
||||||
|
object VideoControlHelper {
|
||||||
|
|
||||||
|
/** 在 doc 及同源 iframe 中递归查找第一个 video 元素 */
|
||||||
|
private const val FIND_VIDEO_FN = """
|
||||||
|
function __tvFindVideo(doc){
|
||||||
|
if(!doc) return null;
|
||||||
|
var v = doc.querySelector('video');
|
||||||
|
if(v) return v;
|
||||||
|
v = doc.querySelector('video.art-video');
|
||||||
|
if(v) return v;
|
||||||
|
v = doc.querySelector('.art-video-player video');
|
||||||
|
if(v) return v;
|
||||||
|
var iframes = doc.querySelectorAll('iframe');
|
||||||
|
for(var i=0;i<iframes.length;i++){
|
||||||
|
try{
|
||||||
|
var idoc = iframes[i].contentDocument;
|
||||||
|
if(idoc){ v = __tvFindVideo(idoc); if(v) return v; }
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
/** 检测页面是否存在 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'; } })();"
|
||||||
|
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
|
||||||
|
|
||||||
|
/** 后退 10 秒,返回 "ok" | "no" */
|
||||||
|
val JS_SEEK_BACK =
|
||||||
|
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; v.currentTime=Math.max(0,v.currentTime-10); return 'ok'; })();"
|
||||||
|
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
|
||||||
|
|
||||||
|
/** 前进 10 秒,返回 "ok" | "no" */
|
||||||
|
val JS_SEEK_FORWARD =
|
||||||
|
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; v.currentTime=Math.min(v.duration||0,v.currentTime+10); return 'ok'; })();"
|
||||||
|
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
|
||||||
|
|
||||||
|
/** 音量减,返回 0~1 数字或 "no" */
|
||||||
|
val JS_VOLUME_DOWN =
|
||||||
|
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; v.volume=Math.max(0,v.volume-0.2); return v.volume; })();"
|
||||||
|
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
|
||||||
|
|
||||||
|
/** 音量加,返回 0~1 数字或 "no" */
|
||||||
|
val JS_VOLUME_UP =
|
||||||
|
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; v.volume=Math.min(1,v.volume+0.2); return v.volume; })();"
|
||||||
|
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
|
||||||
|
|
||||||
|
/** 请求全屏,返回 "ok" | "no" | "unsupported";捕获 Promise 避免 Uncaught Fullscreen is not supported */
|
||||||
|
val JS_FULLSCREEN =
|
||||||
|
"(function(){ $FIND_VIDEO_FN var v=__tvFindVideo(document); if(!v) return 'no'; var fn=v.requestFullscreen||v.webkitRequestFullscreen; if(!fn) return 'unsupported'; try { var p=fn.call(v); if(p&&p.catch) p.catch(function(){}); return 'ok'; } catch(e) { return 'unsupported'; } })();"
|
||||||
|
.replace("$FIND_VIDEO_FN", FIND_VIDEO_FN)
|
||||||
|
|
||||||
|
/** 在 video 上模拟双击(带中心坐标),触发与用户双击视频相同的原生全屏(onShowCustomView),返回 "ok" | "no" */
|
||||||
|
val JS_FULLSCREEN_DBLCLICK =
|
||||||
|
"(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 视频全屏被拦截的问题
|
||||||
|
* 2. 模拟 document.fullscreenEnabled = true,防止网页播放器因检测不支持全屏而禁用全屏按钮
|
||||||
|
* 3. 拦截 Element.prototype.requestFullscreen,失败时自动通知原生 Bridge 进行应用级全屏兜底
|
||||||
|
* 4. 监听常见网页播放器的“网页全屏”样式(如 Artplayer、DPlayer、Bilibili),联动隐藏电视工具栏
|
||||||
|
*/
|
||||||
|
val JS_ENABLE_FULLSCREEN_ENHANCEMENT = """
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
if (window.__tvFullscreenHelperInjected) return;
|
||||||
|
window.__tvFullscreenHelperInjected = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
Object.defineProperty(document, 'fullscreenEnabled', { get: function() { return true; }, configurable: true });
|
||||||
|
Object.defineProperty(document, 'webkitFullscreenEnabled', { get: function() { return true; }, configurable: true });
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
function patchIframes() {
|
||||||
|
var iframes = document.querySelectorAll('iframe');
|
||||||
|
for (var i = 0; i < iframes.length; i++) {
|
||||||
|
var f = iframes[i];
|
||||||
|
if (!f.hasAttribute('allowfullscreen')) f.setAttribute('allowfullscreen', 'true');
|
||||||
|
if (!f.hasAttribute('webkitallowfullscreen')) f.setAttribute('webkitallowfullscreen', 'true');
|
||||||
|
if (!f.hasAttribute('mozallowfullscreen')) f.setAttribute('mozallowfullscreen', 'true');
|
||||||
|
var allow = f.getAttribute('allow') || '';
|
||||||
|
if (allow.indexOf('fullscreen') === -1) {
|
||||||
|
f.setAttribute('allow', (allow ? allow + '; ' : '') + 'fullscreen *; autoplay *; encrypted-media *');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patchIframes();
|
||||||
|
try {
|
||||||
|
new MutationObserver(patchIframes).observe(document.documentElement || document.body, { childList: true, subtree: true });
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
var origReq = Element.prototype.requestFullscreen || Element.prototype.webkitRequestFullscreen;
|
||||||
|
function enhancedRequestFullscreen(options) {
|
||||||
|
var target = this;
|
||||||
|
if (window.AndroidFullscreenBridge) {
|
||||||
|
window.AndroidFullscreenBridge.onFullscreenAttempted();
|
||||||
|
}
|
||||||
|
var p = null;
|
||||||
|
if (origReq) {
|
||||||
|
try {
|
||||||
|
p = origReq.call(target, options);
|
||||||
|
} catch(err) {
|
||||||
|
if (window.AndroidFullscreenBridge) window.AndroidFullscreenBridge.onEnterFullscreen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (p && p.catch) {
|
||||||
|
p.catch(function(err) {
|
||||||
|
if (window.AndroidFullscreenBridge) window.AndroidFullscreenBridge.onEnterFullscreen(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return p || Promise.resolve();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Element.prototype.requestFullscreen = enhancedRequestFullscreen;
|
||||||
|
Element.prototype.webkitRequestFullscreen = enhancedRequestFullscreen;
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
var lastWebFullscreen = false;
|
||||||
|
function checkWebFullscreen() {
|
||||||
|
var isWebFs = !!(
|
||||||
|
document.querySelector('.art-fullscreen-web') ||
|
||||||
|
document.querySelector('.dplayer-fulled') ||
|
||||||
|
document.querySelector('.bpx-state-web-fullscreen') ||
|
||||||
|
document.querySelector('[data-fullscreen="web"]') ||
|
||||||
|
document.querySelector('.web-fullscreen') ||
|
||||||
|
document.querySelector('.fullscreen-web')
|
||||||
|
);
|
||||||
|
if (isWebFs !== lastWebFullscreen) {
|
||||||
|
lastWebFullscreen = isWebFs;
|
||||||
|
if (window.AndroidFullscreenBridge) {
|
||||||
|
if (isWebFs) {
|
||||||
|
window.AndroidFullscreenBridge.onEnterFullscreen(false);
|
||||||
|
} else {
|
||||||
|
window.AndroidFullscreenBridge.onExitFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new MutationObserver(checkWebFullscreen).observe(document.documentElement || document.body, {
|
||||||
|
attributes: true, subtree: true, attributeFilter: ['class', 'style']
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
document.addEventListener('fullscreenchange', function() {
|
||||||
|
if (!document.fullscreenElement && window.AndroidFullscreenBridge) {
|
||||||
|
window.AndroidFullscreenBridge.onExitFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('webkitfullscreenchange', function() {
|
||||||
|
if (!document.webkitFullscreenElement && window.AndroidFullscreenBridge) {
|
||||||
|
window.AndroidFullscreenBridge.onExitFullscreen();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/** 退出全屏 JS 片段 */
|
||||||
|
val JS_EXIT_FULLSCREEN = """
|
||||||
|
(function(){
|
||||||
|
try {
|
||||||
|
if (document.exitFullscreen) document.exitFullscreen();
|
||||||
|
else if (document.webkitExitFullscreen) document.webkitExitFullscreen();
|
||||||
|
} catch(e) {}
|
||||||
|
try {
|
||||||
|
var ev = new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, which: 27, bubbles: true });
|
||||||
|
document.dispatchEvent(ev);
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.http.SslError
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.SslErrorHandler
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后台解析器:用一个隐藏的 WebView 打开剧集播放页,借助页面执行前 Hook + 网络嗅探拿到真实视频地址。
|
||||||
|
* 播放器据此可以在不离开全屏的情况下切换选集 / 自动下一集 / 从历史记录续播。
|
||||||
|
*
|
||||||
|
* - 静音解析:mediaPlaybackRequiresUserGesture = true,网页播放器不会出声,但 hls.js 依然会请求 m3u8
|
||||||
|
* - 每次解析新建 WebView,旧页面的回调不会串到新请求
|
||||||
|
*/
|
||||||
|
class VideoResolver(
|
||||||
|
private val context: Context,
|
||||||
|
/** 承载隐藏 WebView 的容器(需已挂到窗口上,1px 大小即可) */
|
||||||
|
private val container: ViewGroup,
|
||||||
|
private val userAgent: String
|
||||||
|
) {
|
||||||
|
interface Callback {
|
||||||
|
fun onResolved(source: VideoSource, siteInfo: SiteInfo?)
|
||||||
|
fun onFailed(reason: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Candidate(val url: String, val kind: String, val frameUrl: String?, val title: String)
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val headerCache = VideoSnifferHelper.RequestHeaderCache()
|
||||||
|
private var webView: WebView? = null
|
||||||
|
private var callback: Callback? = null
|
||||||
|
private var pageUrl: String = ""
|
||||||
|
private var wantSiteInfo = false
|
||||||
|
private var siteInfo: SiteInfo? = null
|
||||||
|
private val candidates = mutableListOf<Candidate>()
|
||||||
|
private var finished = true
|
||||||
|
private var generation = 0
|
||||||
|
private var hasDocStartHook = false
|
||||||
|
private var playlistWaitMs = 0
|
||||||
|
|
||||||
|
private val timeoutRunnable = Runnable {
|
||||||
|
if (!finished) {
|
||||||
|
if (candidates.isNotEmpty()) finalizeNow() else fail("解析超时,未找到可播放的视频")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private val finalizeRunnable = Runnable { finalizeNow() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析指定播放页。
|
||||||
|
* @param needSiteInfo 是否需要同时提取选集信息(例如从历史记录续播时还不知道选集)
|
||||||
|
*/
|
||||||
|
fun resolve(pageUrl: String, needSiteInfo: Boolean, callback: Callback, timeoutMs: Long = 25_000) {
|
||||||
|
cancel()
|
||||||
|
this.pageUrl = pageUrl
|
||||||
|
this.callback = callback
|
||||||
|
this.wantSiteInfo = needSiteInfo
|
||||||
|
this.siteInfo = null
|
||||||
|
this.finished = false
|
||||||
|
candidates.clear()
|
||||||
|
headerCache.clear()
|
||||||
|
playlistWaitMs = 0
|
||||||
|
generation++
|
||||||
|
val wv = createWebView(generation)
|
||||||
|
webView = wv
|
||||||
|
Log.d(TAG, "resolve: $pageUrl (docStartHook=$hasDocStartHook)")
|
||||||
|
wv.loadUrl(pageUrl)
|
||||||
|
handler.postDelayed(timeoutRunnable, timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
finished = true
|
||||||
|
callback = null
|
||||||
|
handler.removeCallbacks(timeoutRunnable)
|
||||||
|
handler.removeCallbacks(finalizeRunnable)
|
||||||
|
destroyWebView()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() = cancel()
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
|
||||||
|
private fun createWebView(gen: Int): WebView {
|
||||||
|
val wv = WebView(context)
|
||||||
|
wv.settings.apply {
|
||||||
|
javaScriptEnabled = true
|
||||||
|
domStorageEnabled = true
|
||||||
|
databaseEnabled = true
|
||||||
|
userAgentString = userAgent
|
||||||
|
mediaPlaybackRequiresUserGesture = true // 静音解析:网页播放器不自动出声
|
||||||
|
blockNetworkImage = true // 不加载图片,加快解析
|
||||||
|
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||||
|
setSupportMultipleWindows(false)
|
||||||
|
javaScriptCanOpenWindowsAutomatically = false
|
||||||
|
}
|
||||||
|
// Client Hints 与 UA 字符串保持一致(与主浏览器同一身份)
|
||||||
|
UserAgentHelper.applyUserAgent(wv.settings, userAgent == BrowserActivity.DESKTOP_UA)
|
||||||
|
wv.settings.userAgentString = userAgent
|
||||||
|
wv.addJavascriptInterface(Bridge(gen), "AndroidVideoBridge")
|
||||||
|
hasDocStartHook = VideoSnifferHelper.installDocumentStartHook(wv)
|
||||||
|
wv.webViewClient = object : WebViewClient() {
|
||||||
|
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
|
||||||
|
val url = request?.url?.toString() ?: return true
|
||||||
|
return AdBlockHelper.isHijackScheme(url) || !(url.startsWith("http://") || url.startsWith("https://"))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? {
|
||||||
|
val url = request?.url?.toString() ?: return null
|
||||||
|
headerCache.put(url, request.requestHeaders)
|
||||||
|
if (AdBlockHelper.isEnabled(context) && AdBlockHelper.shouldBlockUrl(url)) {
|
||||||
|
return AdBlockHelper.createEmptyResponse()
|
||||||
|
}
|
||||||
|
if (VideoSnifferHelper.isVideoStreamUrl(url)) {
|
||||||
|
handler.post { onCandidate(gen, url, "net", null, "") }
|
||||||
|
}
|
||||||
|
// 解析只需要清单与请求头:不下载分片,避免隐藏网页的播放器在后台缓冲,和正在播放的视频抢网速
|
||||||
|
if (!request.isForMainFrame && VideoSnifferHelper.isMediaSegmentUrl(url)) {
|
||||||
|
return VideoSnifferHelper.createRejectedResponse()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
if (!hasDocStartHook) view?.evaluateJavascript(VideoSnifferHelper.JS_DOCUMENT_START_HOOK, null)
|
||||||
|
view?.evaluateJavascript(VideoSnifferHelper.JS_SITE_ADAPTER_MACCMS, null)
|
||||||
|
view?.evaluateJavascript(VideoSnifferHelper.JS_SNIFF_VIDEO, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
|
||||||
|
handler?.proceed() // 与浏览器主界面一致:电视系统时间不准时放行
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 1x1 挂到窗口上:部分内核对未挂载的 WebView 会暂停计时器,导致播放器脚本不执行
|
||||||
|
container.addView(wv, FrameLayout.LayoutParams(1, 1))
|
||||||
|
return wv
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun destroyWebView() {
|
||||||
|
val wv = webView ?: return
|
||||||
|
webView = null
|
||||||
|
try {
|
||||||
|
wv.stopLoading()
|
||||||
|
container.removeView(wv)
|
||||||
|
wv.destroy()
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class Bridge(private val gen: Int) {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onMediaRequest(url: String, kind: String, frameUrl: String, title: String) {
|
||||||
|
handler.post { onCandidate(gen, url, kind, frameUrl, title) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onVideoFound(url: String, title: String) {
|
||||||
|
handler.post { onCandidate(gen, url, "dom", null, title) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onPlaylist(url: String, finalUrl: String, text: String) {
|
||||||
|
PlaylistCache.put(url, finalUrl, text)
|
||||||
|
Log.d(TAG, "playlist captured: $url (${text.length} chars)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onSiteInfo(json: String) {
|
||||||
|
handler.post {
|
||||||
|
if (gen != generation) return@post
|
||||||
|
SiteInfo.fromJson(json)?.let { siteInfo = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主界面 Hook 里可能调用的其它接口,这里空实现避免 JS 报错
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onEnterFullscreen(isNative: Boolean) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onCandidate(gen: Int, url: String, kind: String, frameUrl: String?, title: String) {
|
||||||
|
if (gen != generation || finished) return
|
||||||
|
if (!url.startsWith("http")) return
|
||||||
|
if (VideoSnifferHelper.isWebOnlyStream(url)) {
|
||||||
|
Log.d(TAG, "web-only stream: $url")
|
||||||
|
fail(VideoSnifferHelper.WEB_ONLY_MESSAGE)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (AdBlockHelper.isEnabled(context) && AdBlockHelper.shouldBlockUrl(url)) return
|
||||||
|
if (candidates.any { it.url == url || VideoSnifferHelper.isLikelyVariantOf(url, it.url) }) return
|
||||||
|
candidates.add(Candidate(url, kind, frameUrl, title))
|
||||||
|
Log.d(TAG, "candidate[$kind]: $url")
|
||||||
|
handler.removeCallbacks(finalizeRunnable)
|
||||||
|
if (candidates.any { VideoSnifferHelper.isHls(it.url, it.kind) }) {
|
||||||
|
// 拿到 HLS:稍等片刻让对应的网络请求头进入缓存
|
||||||
|
handler.postDelayed(finalizeRunnable, 600)
|
||||||
|
} else {
|
||||||
|
// 只有 mp4/flv(可能是贴片广告):多等一会儿看看有没有 HLS
|
||||||
|
handler.postDelayed(finalizeRunnable, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finalizeNow() {
|
||||||
|
if (finished) return
|
||||||
|
val best = candidates.firstOrNull { VideoSnifferHelper.isHls(it.url, it.kind) } ?: candidates.firstOrNull()
|
||||||
|
if (best == null) {
|
||||||
|
fail("未找到可播放的视频")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// HLS:等网页的 hls.js 把清单原文回传(一次性链接只能用这份内容),最多再等约 5 秒
|
||||||
|
if (VideoSnifferHelper.isHls(best.url, best.kind) && !PlaylistCache.isReady(best.url) && playlistWaitMs < 5000) {
|
||||||
|
playlistWaitMs += 300
|
||||||
|
handler.postDelayed(finalizeRunnable, 300)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 需要选集信息但还没拿到:主动再跑一次适配脚本,最多再等 1 秒
|
||||||
|
if (wantSiteInfo && siteInfo == null) {
|
||||||
|
wantSiteInfo = false
|
||||||
|
webView?.evaluateJavascript(VideoSnifferHelper.JS_SITE_ADAPTER_MACCMS, null)
|
||||||
|
handler.postDelayed(finalizeRunnable, 1000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val headers = VideoSnifferHelper.buildPlaybackHeaders(
|
||||||
|
url = best.url,
|
||||||
|
captured = headerCache.get(best.url),
|
||||||
|
userAgent = userAgent,
|
||||||
|
fallbackReferer = best.frameUrl ?: pageUrl
|
||||||
|
)
|
||||||
|
val info = siteInfo
|
||||||
|
val title = info?.displayTitle()?.takeIf { it.isNotBlank() } ?: best.title
|
||||||
|
val source = VideoSource(
|
||||||
|
url = best.url,
|
||||||
|
title = title,
|
||||||
|
pageUrl = pageUrl,
|
||||||
|
headers = headers,
|
||||||
|
isHls = VideoSnifferHelper.isHls(best.url, best.kind)
|
||||||
|
)
|
||||||
|
Log.d(TAG, "resolved: ${source.url} headers=${headers.keys}")
|
||||||
|
val cb = callback
|
||||||
|
finished = true
|
||||||
|
handler.removeCallbacks(timeoutRunnable)
|
||||||
|
// 先回调再销毁(避免在 WebView 回调栈里销毁自身)
|
||||||
|
cb?.onResolved(source, info)
|
||||||
|
handler.post { if (finished) destroyWebView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fail(reason: String) {
|
||||||
|
if (finished) return
|
||||||
|
val cb = callback
|
||||||
|
finished = true
|
||||||
|
handler.removeCallbacks(timeoutRunnable)
|
||||||
|
handler.removeCallbacks(finalizeRunnable)
|
||||||
|
cb?.onFailed(reason)
|
||||||
|
handler.post { if (finished) destroyWebView() }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "TVBrowser.Resolver"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,963 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import android.webkit.WebResourceResponse
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.webkit.WebViewCompat
|
||||||
|
import androidx.webkit.WebViewFeature
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 视频流嗅探工具:
|
||||||
|
* 1. 拦截与识别网络请求中的视频流地址(.m3u8, .mp4, .flv 等)
|
||||||
|
* 2. 注入 JS 动态提取网页内的 <video> 播放源并回调宿主
|
||||||
|
* 3. 页面执行前注入 Hook(XHR / fetch / hls.js / video.src),可进入所有 iframe,抓取 blob: 背后的真实 m3u8
|
||||||
|
* 4. 记录请求头,供原生播放器原样回放(Referer / UA / Cookie)
|
||||||
|
* 5. 站点适配脚本(苹果 CMS 通用):提取片名、当前集、选集列表、下一集
|
||||||
|
*/
|
||||||
|
object VideoSnifferHelper {
|
||||||
|
|
||||||
|
private const val TAG = "TVBrowser.Sniffer"
|
||||||
|
|
||||||
|
private val VIDEO_EXTENSIONS = arrayOf(".m3u8", ".mp4", ".flv", ".f4v", ".webm")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断一个请求 URL 是否为多媒体视频流地址
|
||||||
|
*/
|
||||||
|
fun isVideoStreamUrl(url: String): Boolean {
|
||||||
|
val lower = url.lowercase().trim()
|
||||||
|
if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 排除常见的带有 mp4/m3u8 字符串的非视频文件(如 .jpg, .png, .js, .css, .html)
|
||||||
|
if (lower.endsWith(".jpg") || lower.endsWith(".png") || lower.endsWith(".gif") ||
|
||||||
|
lower.endsWith(".js") || lower.endsWith(".css") || lower.endsWith(".html") ||
|
||||||
|
lower.endsWith(".ts") || lower.endsWith(".key")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 Path
|
||||||
|
val path = try {
|
||||||
|
Uri.parse(url).path?.lowercase() ?: ""
|
||||||
|
} catch (_: Exception) {
|
||||||
|
lower
|
||||||
|
}
|
||||||
|
|
||||||
|
for (ext in VIDEO_EXTENSIONS) {
|
||||||
|
if (path.endsWith(ext) || lower.contains("$ext?") || lower.contains("$ext#") || lower.endsWith(ext)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否 HLS:显式标记为 hls,或地址含 .m3u8 */
|
||||||
|
fun isHls(url: String, kind: String? = null): Boolean =
|
||||||
|
kind == "hls" || url.contains(".m3u8", ignoreCase = true)
|
||||||
|
|
||||||
|
private val SEGMENT_EXTENSIONS = arrayOf(".ts", ".m4s", ".aac", ".m4a")
|
||||||
|
/** 不少站点把 TS 分片伪装成图片后缀,用 XHR 加载 */
|
||||||
|
private val DISGUISED_SEGMENT_EXTENSIONS = arrayOf(".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否像 HLS / DASH 的媒体分片(只在后台解析 WebView 里用来拦截:解析只需要清单和请求头,不需要分片)。
|
||||||
|
* 后台解析 WebView 已开启 blockNetworkImage,能走到这里的图片后缀请求基本是 XHR 加载的伪装分片。
|
||||||
|
*/
|
||||||
|
fun isMediaSegmentUrl(url: String): Boolean {
|
||||||
|
val path = try {
|
||||||
|
Uri.parse(url).path?.lowercase() ?: return false
|
||||||
|
} catch (_: Exception) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return SEGMENT_EXTENSIONS.any { path.endsWith(it) } || DISGUISED_SEGMENT_EXTENSIONS.any { path.endsWith(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拒绝请求(403):网页播放器会认为分片加载失败并停止,而不是拿到空数据反复解析 */
|
||||||
|
fun createRejectedResponse(): WebResourceResponse =
|
||||||
|
WebResourceResponse("text/plain", "utf-8", 403, "Blocked", emptyMap(), ByteArrayInputStream(ByteArray(0)))
|
||||||
|
|
||||||
|
/** 原生播放器无法播放时给用户的提示 */
|
||||||
|
const val WEB_ONLY_MESSAGE = "央视网视频经过加密,原生播放器会花屏,请直接在网页中播放"
|
||||||
|
|
||||||
|
/** 央视网 CNTV 的 h5e / enc / enc2 HLS 路径(普通 /asp/hls/ 不在内,但只有 270p) */
|
||||||
|
private val WEB_ONLY_PATH = Regex("""/asp/(h5e|enc2?)/""", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只能在网页内播放的视频流:画面数据经站点私有算法混淆,由网页播放器(wasm)边播边还原,
|
||||||
|
* 原生播放器 / 外部播放器直接解码会花屏(有声音、画面马赛克)。
|
||||||
|
* 目前:央视网 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
|
||||||
|
if (!host.contains("cntv") && !host.contains("cctv")) return false
|
||||||
|
return WEB_ONLY_PATH.containsMatchIn(uri.path ?: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新的 m3u8 是否只是已有 m3u8 的子清单(主清单 → 码率子清单),用于去重。
|
||||||
|
* 规则:同域名,且新地址所在目录以旧地址所在目录开头。
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// 请求头记录与回放
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 播放时不应原样回放的请求头 */
|
||||||
|
private val SKIP_HEADERS = setOf(
|
||||||
|
"range", "accept-encoding", "host", "content-length", "connection",
|
||||||
|
"if-none-match", "if-modified-since", "upgrade-insecure-requests"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 线程安全的 LRU:记录 WebView 发出的请求头(shouldInterceptRequest 在后台线程回调)。
|
||||||
|
*/
|
||||||
|
class RequestHeaderCache(private val maxSize: Int = 300) {
|
||||||
|
private val map = object : LinkedHashMap<String, Map<String, String>>(64, 0.75f, true) {
|
||||||
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Map<String, String>>?): Boolean =
|
||||||
|
size > maxSize
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun put(url: String, headers: Map<String, String>?) {
|
||||||
|
map[url] = headers ?: emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun get(url: String): Map<String, String>? = map[url]
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun clear() = map.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装原生播放器要用的请求头:
|
||||||
|
* - 优先使用 WebView 实际发出的请求头(最能还原站点校验条件)
|
||||||
|
* - 补充 User-Agent 与 Cookie
|
||||||
|
* - 没有抓到原始请求头时,用来源页作为 Referer 兜底
|
||||||
|
*/
|
||||||
|
fun buildPlaybackHeaders(
|
||||||
|
url: String,
|
||||||
|
captured: Map<String, String>?,
|
||||||
|
userAgent: String,
|
||||||
|
fallbackReferer: String?
|
||||||
|
): Map<String, String> {
|
||||||
|
val headers = LinkedHashMap<String, String>()
|
||||||
|
captured?.forEach { (k, v) ->
|
||||||
|
val lk = k.lowercase()
|
||||||
|
if (lk !in SKIP_HEADERS && !lk.startsWith("sec-")) headers[k] = v
|
||||||
|
}
|
||||||
|
fun has(name: String) = headers.keys.any { it.equals(name, ignoreCase = true) }
|
||||||
|
if (!has("User-Agent") && userAgent.isNotBlank()) headers["User-Agent"] = userAgent
|
||||||
|
try {
|
||||||
|
val cookie = CookieManager.getInstance().getCookie(url)
|
||||||
|
if (!cookie.isNullOrBlank() && !has("Cookie")) headers["Cookie"] = cookie
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
if (captured == null && !fallbackReferer.isNullOrBlank() && !has("Referer")) {
|
||||||
|
headers["Referer"] = fallbackReferer
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
// 页面执行前注入
|
||||||
|
// ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** 当前 WebView 是否支持「页面执行前 + 所有 iframe」注入 */
|
||||||
|
fun supportsDocumentStartScript(): Boolean =
|
||||||
|
try {
|
||||||
|
WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装页面执行前 Hook(对之后加载的所有页面、所有 frame 生效)。
|
||||||
|
* @return true 表示已安装;false 表示系统 WebView 太旧,调用方需在 onPageStarted 用 evaluateJavascript 兜底
|
||||||
|
*/
|
||||||
|
fun installDocumentStartHook(webView: WebView): Boolean {
|
||||||
|
if (!supportsDocumentStartScript()) return false
|
||||||
|
return try {
|
||||||
|
WebViewCompat.addDocumentStartJavaScript(webView, JS_DOCUMENT_START_HOOK, setOf("*"))
|
||||||
|
true
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w(TAG, "addDocumentStartJavaScript failed", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 页面执行前 Hook:在每个 frame(含跨域 iframe)里抓取真实视频地址。
|
||||||
|
* - XHR / fetch:地址带视频后缀,或响应是 m3u8(Content-Type 含 mpegurl / 内容以 #EXTM3U 开头)
|
||||||
|
* - hls.js:拦截 Hls.prototype.loadSource,直接拿到传给 hls.js 的地址(即使没有 .m3u8 后缀)
|
||||||
|
* - video.src 赋值(排除 blob: / data:)
|
||||||
|
* 通过 AndroidVideoBridge.onMediaRequest(url, kind, frameUrl, title) 回传原生层。
|
||||||
|
* 同时监听 {__tvcmd:'pause'} / {__tvcmd:'resume'} 消息,供原生层暂停网页内所有视频并停止 / 恢复 hls.js 缓冲(含 iframe)。
|
||||||
|
*/
|
||||||
|
val JS_DOCUMENT_START_HOOK = """
|
||||||
|
(function() {
|
||||||
|
if (window.__tvSniffInstalled) return;
|
||||||
|
window.__tvSniffInstalled = true;
|
||||||
|
var reported = {};
|
||||||
|
var hlsReported = false;
|
||||||
|
var RE_MEDIA = /\.(m3u8|mp4|flv|f4v|webm)(\?|#|$)/i;
|
||||||
|
function pageTitle() {
|
||||||
|
try { if (window.top && window.top.document && window.top.document.title) return window.top.document.title; } catch (e) {}
|
||||||
|
return document.title || '';
|
||||||
|
}
|
||||||
|
function report(u, kind) {
|
||||||
|
try {
|
||||||
|
if (!u) return;
|
||||||
|
u = String(u);
|
||||||
|
if (u.indexOf('blob:') === 0 || u.indexOf('data:') === 0) return;
|
||||||
|
var abs = new URL(u, location.href).href;
|
||||||
|
if (!/^https?:/i.test(abs)) return;
|
||||||
|
if (reported[abs]) return;
|
||||||
|
reported[abs] = 1;
|
||||||
|
if (kind === 'hls' || /\.m3u8/i.test(abs)) hlsReported = true;
|
||||||
|
var b = window.AndroidVideoBridge;
|
||||||
|
if (b && b.onMediaRequest) b.onMediaRequest(abs, kind || '', location.href, pageTitle());
|
||||||
|
else if (b && b.onVideoFound) b.onVideoFound(abs, pageTitle());
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
// 回传清单原文:很多站点的 m3u8 是一次性链接,原生播放器需直接使用网页已下载的内容
|
||||||
|
function sendPlaylist(u, finalUrl, text) {
|
||||||
|
try {
|
||||||
|
if (!u || typeof text !== 'string' || text.slice(0, 7) !== '#EXTM3U') return;
|
||||||
|
var abs = new URL(String(u), location.href).href;
|
||||||
|
var fin = finalUrl ? new URL(String(finalUrl), location.href).href : abs;
|
||||||
|
var b = window.AndroidVideoBridge;
|
||||||
|
if (b && b.onPlaylist) b.onPlaylist(abs, fin, text);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var XO = XMLHttpRequest.prototype.open, XS = XMLHttpRequest.prototype.send;
|
||||||
|
XMLHttpRequest.prototype.open = function(m, u) { this.__tvUrl = u; return XO.apply(this, arguments); };
|
||||||
|
XMLHttpRequest.prototype.send = function() {
|
||||||
|
var x = this;
|
||||||
|
try {
|
||||||
|
if (x.__tvUrl && RE_MEDIA.test(String(x.__tvUrl))) report(x.__tvUrl, 'xhr');
|
||||||
|
x.addEventListener('load', function() {
|
||||||
|
try {
|
||||||
|
var text = ((x.responseType === '' || x.responseType === 'text') && typeof x.responseText === 'string') ? x.responseText : null;
|
||||||
|
if (text && text.slice(0, 7) === '#EXTM3U') sendPlaylist(x.__tvUrl, x.responseURL, text);
|
||||||
|
if (hlsReported) return; // 已拿到主清单,忽略后续子清单
|
||||||
|
var ct = (x.getResponseHeader('content-type') || '').toLowerCase();
|
||||||
|
var isM3u8 = ct.indexOf('mpegurl') >= 0;
|
||||||
|
if (!isM3u8 && text) isM3u8 = text.slice(0, 7) === '#EXTM3U';
|
||||||
|
if (isM3u8) report(x.responseURL || x.__tvUrl, 'hls');
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
return XS.apply(this, arguments);
|
||||||
|
};
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
if (window.fetch) {
|
||||||
|
var F = window.fetch;
|
||||||
|
window.fetch = function(input, init) {
|
||||||
|
var u = (typeof input === 'string') ? input : (input && input.url);
|
||||||
|
if (u && RE_MEDIA.test(u)) report(u, 'fetch');
|
||||||
|
var p = F.apply(this, arguments);
|
||||||
|
try {
|
||||||
|
p.then(function(r) {
|
||||||
|
try {
|
||||||
|
var ct = (r.headers.get('content-type') || '').toLowerCase();
|
||||||
|
var maybeM3u8 = ct.indexOf('mpegurl') >= 0 || /\.m3u8/i.test(String(u || '')) ||
|
||||||
|
(ct.indexOf('text/') === 0 && Number(r.headers.get('content-length') || 0) < 4194304);
|
||||||
|
if (maybeM3u8) {
|
||||||
|
r.clone().text().then(function(t) {
|
||||||
|
if (t && t.slice(0, 7) === '#EXTM3U') {
|
||||||
|
sendPlaylist(u, r.url, t);
|
||||||
|
if (!hlsReported) report(r.url || u, 'hls');
|
||||||
|
}
|
||||||
|
}, function() {});
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
}, function() {});
|
||||||
|
} catch (e) {}
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
// 记录页面里的 hls.js 实例:切到原生播放器时 stopLoad(),否则网页播放器暂停后仍会在后台继续缓冲分片,和原生播放器抢网速
|
||||||
|
var hlsList = [];
|
||||||
|
function trackHls(h) {
|
||||||
|
try {
|
||||||
|
if (!h || hlsList.indexOf(h) >= 0) return;
|
||||||
|
hlsList.push(h);
|
||||||
|
if (hlsList.length > 20) hlsList.shift();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
function wrapHls(H) {
|
||||||
|
try {
|
||||||
|
if (!H || !H.prototype || H.prototype.__tvWrapped) return H;
|
||||||
|
var L = H.prototype.loadSource, A = H.prototype.attachMedia, D = H.prototype.destroy;
|
||||||
|
if (typeof L === 'function') {
|
||||||
|
H.prototype.loadSource = function(src) { trackHls(this); report(src, 'hls'); return L.apply(this, arguments); };
|
||||||
|
H.prototype.__tvWrapped = true;
|
||||||
|
}
|
||||||
|
if (typeof A === 'function') {
|
||||||
|
H.prototype.attachMedia = function() { trackHls(this); return A.apply(this, arguments); };
|
||||||
|
}
|
||||||
|
if (typeof D === 'function') {
|
||||||
|
H.prototype.destroy = function() {
|
||||||
|
try { var i = hlsList.indexOf(this); if (i >= 0) hlsList.splice(i, 1); } catch (e) {}
|
||||||
|
return D.apply(this, arguments);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return H;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var _H = window.Hls;
|
||||||
|
if (_H) wrapHls(_H);
|
||||||
|
Object.defineProperty(window, 'Hls', {
|
||||||
|
configurable: true,
|
||||||
|
get: function() { return _H; },
|
||||||
|
set: function(v) { _H = wrapHls(v); }
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
var d = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'src');
|
||||||
|
if (d && d.set) {
|
||||||
|
Object.defineProperty(HTMLMediaElement.prototype, 'src', {
|
||||||
|
configurable: true, enumerable: d.enumerable, get: d.get,
|
||||||
|
set: function(v) { report(v, 'video'); return d.set.call(this, v); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
window.addEventListener('message', function(e) {
|
||||||
|
try {
|
||||||
|
var data = e.data;
|
||||||
|
if (!data || (data.__tvcmd !== 'pause' && data.__tvcmd !== 'resume')) return;
|
||||||
|
var i;
|
||||||
|
if (data.__tvcmd === 'pause') {
|
||||||
|
var vs = document.querySelectorAll('video,audio');
|
||||||
|
for (i = 0; i < vs.length; i++) { try { vs[i].pause(); } catch (_) {} }
|
||||||
|
// 暂停后 hls.js 仍会缓冲到上限(不少站点设到几分钟),停止加载,回到网页时再恢复
|
||||||
|
for (i = 0; i < hlsList.length; i++) {
|
||||||
|
try { if (!hlsList[i].__tvSuspended) { hlsList[i].stopLoad(); hlsList[i].__tvSuspended = true; } } catch (_) {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (i = 0; i < hlsList.length; i++) {
|
||||||
|
try { if (hlsList[i].__tvSuspended) { hlsList[i].__tvSuspended = false; hlsList[i].startLoad(); } } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (var j = 0; j < window.frames.length; j++) { try { window.frames[j].postMessage(data, '*'); } catch (_) {} }
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/** 暂停网页内所有音视频(主页面 + 同源 iframe 直接暂停;跨域 iframe 通过 postMessage 交给 Hook 处理) */
|
||||||
|
val JS_PAUSE_ALL_MEDIA = """
|
||||||
|
(function() {
|
||||||
|
function pauseIn(doc) {
|
||||||
|
if (!doc) return;
|
||||||
|
var vs = doc.querySelectorAll('video,audio');
|
||||||
|
for (var i = 0; i < vs.length; i++) { try { vs[i].pause(); } catch (e) {} }
|
||||||
|
var fs = doc.querySelectorAll('iframe');
|
||||||
|
for (var k = 0; k < fs.length; k++) { try { pauseIn(fs[k].contentDocument); } catch (e) {} }
|
||||||
|
}
|
||||||
|
try { pauseIn(document); } catch (e) {}
|
||||||
|
try { window.postMessage({ __tvcmd: 'pause' }, '*'); } catch (e) {}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/** 从原生 / 外部播放器回到网页:恢复被 [JS_PAUSE_ALL_MEDIA] 停止加载的 hls.js(只恢复缓冲,不自动播放) */
|
||||||
|
const val JS_RESUME_MEDIA_LOADING = "try{window.postMessage({__tvcmd:'resume'},'*');}catch(e){}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点适配:苹果 CMS(MacCMS)通用。
|
||||||
|
* 识别页面中的 player_aaaa / player_data 变量;选集列表通过「与当前播放页 URL 结构相同、只有最后一个数字不同」的链接推断,
|
||||||
|
* 适配 /vodplay/1-1-1.html、/play/1-1-1/、/index.php/vod/play/id/1/sid/1/nid/1.html 等常见规则。
|
||||||
|
* 结果通过 AndroidVideoBridge.onSiteInfo(json) 回传。
|
||||||
|
*/
|
||||||
|
val JS_SITE_ADAPTER_MACCMS = """
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
if (!window.AndroidVideoBridge || !window.AndroidVideoBridge.onSiteInfo) return;
|
||||||
|
|
||||||
|
function parseUrl(u) {
|
||||||
|
try {
|
||||||
|
var a = new URL(u, location.href);
|
||||||
|
a.hash = '';
|
||||||
|
return a;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normKey(u) {
|
||||||
|
if (!u) return '';
|
||||||
|
var p = (u.pathname || '').replace(/\/+$/, '') || '/';
|
||||||
|
return (p + (u.search || '')).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryDecodeB64(str) {
|
||||||
|
try {
|
||||||
|
var clean = (str || '').replace(/[^A-Za-z0-9+/=]/g, '');
|
||||||
|
if (clean.length < 4) return null;
|
||||||
|
while (clean.length % 4 !== 0) clean += '=';
|
||||||
|
var dec = window.atob(clean);
|
||||||
|
return dec && /^[A-Za-z0-9_\-\.]+$/.test(dec) ? dec : null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cur = parseUrl(location.href);
|
||||||
|
if (!cur) return;
|
||||||
|
var curKey = normKey(cur);
|
||||||
|
var curPath = (cur.pathname || '').replace(/\/+$/, '').toLowerCase();
|
||||||
|
var BAD = /上一|下一|上集|下集|播放|立即|观看|开始|继续|下载|APP|倒序|正序|选集|切换|收藏|分享|评论|报错|刷新|换源|排序|^(prev|previous|next|all)$/i;
|
||||||
|
|
||||||
|
/** 选集名称:保留网页原文(仅去掉图标字体字符与多余空白),网页没有名称时才兜底为「第X集」 */
|
||||||
|
function cleanName(rawText, fallbackIndex) {
|
||||||
|
var s = (rawText || '').replace(/[\uE000-\uF8FF]/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!s) return '第' + (fallbackIndex != null ? fallbackIndex : 1) + '集';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pd = window.player_aaaa || window.player_data || null;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 策略 1: 扫描页面选集 DOM 容器(通杀 4kvm、4kcz、MacCMS、SeaCMS 等绝大多数站点)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
var candidates = [];
|
||||||
|
var seenSet = [];
|
||||||
|
function addCandidate(el) {
|
||||||
|
if (!el || seenSet.indexOf(el) >= 0) return;
|
||||||
|
seenSet.push(el);
|
||||||
|
candidates.push(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
var SELECTORS = [
|
||||||
|
'.anthology-list-play', '.anthology-list',
|
||||||
|
'.content_playlist', '.stui-content__playlist',
|
||||||
|
'.myui-content__list', '.module-play-list-content',
|
||||||
|
'.module-play-list', '.module-list',
|
||||||
|
'.playlist', '.play-list', '.play_list', '.episodios', '.ff-playurl',
|
||||||
|
'.play-list-box', '.episode-list', '.episodes-list',
|
||||||
|
'[class*="anthology"]', '[class*="playlist"]',
|
||||||
|
'[class*="play-list"]', '[class*="episode-list"]',
|
||||||
|
'[class*="episodes"]', '[x-data*="episode"]',
|
||||||
|
'[id*="episode"]', '[id*="playlist"]',
|
||||||
|
'div[style*="episodes"]'
|
||||||
|
];
|
||||||
|
for (var s = 0; s < SELECTORS.length; s++) {
|
||||||
|
var els = document.querySelectorAll(SELECTORS[s]);
|
||||||
|
for (var k = 0; k < els.length; k++) addCandidate(els[k]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从显式剧集链接直接寻找其父级容器(适配 4kvm 等现代框架动态类名页面)
|
||||||
|
var epLinkA = document.querySelectorAll('a.episode-link, a[class*="episode-link"], a[data-episode], a[data-nid]');
|
||||||
|
for (var ea = 0; ea < epLinkA.length; ea++) {
|
||||||
|
var p = epLinkA[ea].parentElement;
|
||||||
|
if (p) addCandidate(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
var allContainers = document.querySelectorAll('ul, ol, div[class*="play"], div[class*="anthology"], div[class*="episode"], div[class*="grid"], div[class*="list"], div[x-data]');
|
||||||
|
for (var cIdx = 0; cIdx < allContainers.length; cIdx++) {
|
||||||
|
var c = allContainers[cIdx];
|
||||||
|
var cLinks = c.querySelectorAll('a[href]');
|
||||||
|
if (cLinks.length >= 2 && cLinks.length <= 500) {
|
||||||
|
var epMatchCount = 0;
|
||||||
|
for (var lIdx = 0; lIdx < Math.min(cLinks.length, 10); lIdx++) {
|
||||||
|
var aElC = cLinks[lIdx];
|
||||||
|
var lt = (aElC.getAttribute('data-episode') || aElC.getAttribute('data-num') || aElC.textContent || '').trim();
|
||||||
|
if (/^第?\s*\d+\s*[集话回期]?$/.test(lt) || /^\d+$/.test(lt)) epMatchCount++;
|
||||||
|
}
|
||||||
|
if (epMatchCount >= 2) addCandidate(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
var allLinks = document.querySelectorAll('a[href]');
|
||||||
|
for (var al = 0; al < allLinks.length; al++) {
|
||||||
|
var aTag = allLinks[al];
|
||||||
|
var aText = (aTag.getAttribute('data-episode') || aTag.getAttribute('data-num') || aTag.textContent || '').trim();
|
||||||
|
if (/^第?\s*\d+\s*[集话回期]?$/.test(aText) || /^\d+$/.test(aText)) {
|
||||||
|
var parentP = aTag.parentElement;
|
||||||
|
if (parentP && parentP.tagName !== 'BODY' && parentP.tagName !== 'HTML') {
|
||||||
|
if (parentP.tagName === 'LI' && parentP.parentElement) {
|
||||||
|
addCandidate(parentP.parentElement);
|
||||||
|
} else {
|
||||||
|
addCandidate(parentP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤掉包含其他候选容器的祖先容器,保留最具体的分组容器(避免把多条线路合并为重复单列)
|
||||||
|
var leafCandidates = candidates.filter(function(c1) {
|
||||||
|
return !candidates.some(function(c2) {
|
||||||
|
return c1 !== c2 && c1.contains(c2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
var effectiveCandidates = leafCandidates.length > 0 ? leafCandidates : candidates;
|
||||||
|
|
||||||
|
var domSources = [];
|
||||||
|
var activeDomIndex = -1;
|
||||||
|
|
||||||
|
for (var i = 0; i < effectiveCandidates.length; i++) {
|
||||||
|
var container = effectiveCandidates[i];
|
||||||
|
var aTags = container.querySelectorAll('a[href]');
|
||||||
|
if (aTags.length === 0) continue;
|
||||||
|
|
||||||
|
var epList = [];
|
||||||
|
var hasCur = false;
|
||||||
|
var seenUrls = {};
|
||||||
|
for (var j = 0; j < aTags.length; j++) {
|
||||||
|
var aEl = aTags[j];
|
||||||
|
var parsedA = parseUrl(aEl.getAttribute('href'));
|
||||||
|
if (!parsedA || parsedA.host !== cur.host) continue;
|
||||||
|
|
||||||
|
var aKey = normKey(parsedA);
|
||||||
|
var isCurrent = (aKey === curKey);
|
||||||
|
if (!isCurrent) {
|
||||||
|
var aNormPath = (parsedA.pathname || '').replace(/\/+$/, '').toLowerCase();
|
||||||
|
if (aNormPath === curPath && (!parsedA.search || parsedA.search === cur.search)) {
|
||||||
|
isCurrent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isCurrent && (
|
||||||
|
aEl.classList.contains('active') ||
|
||||||
|
aEl.classList.contains('current') ||
|
||||||
|
aEl.classList.contains('on') ||
|
||||||
|
aEl.getAttribute('aria-current') === 'page' ||
|
||||||
|
aEl.getAttribute('aria-selected') === 'true'
|
||||||
|
)) {
|
||||||
|
isCurrent = true;
|
||||||
|
}
|
||||||
|
if (isCurrent) hasCur = true;
|
||||||
|
|
||||||
|
// 名称优先使用网页上实际显示的文字(去掉角标 / 播放动画等装饰节点)
|
||||||
|
var clone = aEl.cloneNode(true);
|
||||||
|
var badNodes = clone.querySelectorAll('.badge, .tag, sup, small, .playing-bars, [class*="badge"], [class*="tag"]');
|
||||||
|
for (var bz = 0; bz < badNodes.length; bz++) badNodes[bz].remove();
|
||||||
|
var rawName = (clone.textContent || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!rawName) {
|
||||||
|
rawName = (aEl.getAttribute('title') ||
|
||||||
|
aEl.getAttribute('data-title') ||
|
||||||
|
aEl.getAttribute('data-text') ||
|
||||||
|
aEl.getAttribute('data-episode') ||
|
||||||
|
aEl.getAttribute('data-num') ||
|
||||||
|
aEl.getAttribute('data-index') || '').trim();
|
||||||
|
}
|
||||||
|
if (BAD.test(rawName)) continue;
|
||||||
|
|
||||||
|
// 同一集的第二个链接(如缩略图 + 标题):之前没有文字时用这次的文字作名称
|
||||||
|
if (seenUrls[parsedA.href]) {
|
||||||
|
var prevEp = seenUrls[parsedA.href];
|
||||||
|
if (prevEp.noName && rawName) { prevEp.name = cleanName(rawName); prevEp.noName = false; }
|
||||||
|
if (isCurrent) prevEp.isCurrent = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
var epItem = {
|
||||||
|
name: cleanName(rawName, epList.length + 1),
|
||||||
|
url: parsedA.href,
|
||||||
|
isCurrent: isCurrent,
|
||||||
|
noName: !rawName
|
||||||
|
};
|
||||||
|
seenUrls[parsedA.href] = epItem;
|
||||||
|
epList.push(epItem);
|
||||||
|
}
|
||||||
|
if (epList.length >= 1) {
|
||||||
|
domSources.push({
|
||||||
|
element: container,
|
||||||
|
episodes: epList,
|
||||||
|
hasCurrent: hasCur
|
||||||
|
});
|
||||||
|
if (hasCur && activeDomIndex < 0) {
|
||||||
|
activeDomIndex = domSources.length - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 策略 2: 厂长 / 4kcz Base64 路由专用匹配 (/v_play/bXZf... )
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
var b64Episodes = [];
|
||||||
|
var b64Match = cur.pathname.match(/\/v_play\/([A-Za-z0-9+/=]+)/);
|
||||||
|
if (b64Match) {
|
||||||
|
var curDec = tryDecodeB64(b64Match[1]);
|
||||||
|
if (curDec) {
|
||||||
|
var prefixMatch = curDec.match(/(.+?)[_-]nm[_-](\d+)/);
|
||||||
|
var targetPrefix = prefixMatch ? prefixMatch[1] : curDec;
|
||||||
|
var allPageLinks = document.querySelectorAll('a[href*="/v_play/"]');
|
||||||
|
var b64Map = {};
|
||||||
|
for (var bp = 0; bp < allPageLinks.length; bp++) {
|
||||||
|
var bHref = allPageLinks[bp].getAttribute('href');
|
||||||
|
var bSubMatch = (bHref || '').match(/\/v_play\/([A-Za-z0-9+/=]+)/);
|
||||||
|
if (!bSubMatch) continue;
|
||||||
|
var bDec = tryDecodeB64(bSubMatch[1]);
|
||||||
|
if (!bDec) continue;
|
||||||
|
var bParts = bDec.match(/(.+?)[_-]nm[_-](\d+)/);
|
||||||
|
if (!bParts || bParts[1] !== targetPrefix) continue;
|
||||||
|
var bNid = parseInt(bParts[2], 10);
|
||||||
|
var bFull = parseUrl(bHref);
|
||||||
|
if (!bFull) continue;
|
||||||
|
var bTxt = (allPageLinks[bp].textContent || allPageLinks[bp].getAttribute('title') || '').trim();
|
||||||
|
if (BAD.test(bTxt)) continue;
|
||||||
|
if (!b64Map[bNid]) {
|
||||||
|
b64Map[bNid] = {
|
||||||
|
nid: bNid,
|
||||||
|
name: cleanName(bTxt, bNid),
|
||||||
|
url: bFull.href
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var bKeys = Object.keys(b64Map).map(function(k) { return parseInt(k, 10); }).sort(function(a, b) { return a - b; });
|
||||||
|
b64Episodes = bKeys.map(function(k) { return b64Map[k]; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 策略 3: MacCMS 经典纯数字 URL 骨架推导
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
var curNums = cur.pathname.match(/\d+/g) || [];
|
||||||
|
var curSkel = cur.pathname.replace(/\d+/g, '#');
|
||||||
|
var maccmsEpisodes = [];
|
||||||
|
var maccmsSources = [];
|
||||||
|
var maccmsSourceIndex = -1;
|
||||||
|
|
||||||
|
if (curNums.length > 0) {
|
||||||
|
var map = {}, order = [];
|
||||||
|
var as = document.querySelectorAll('a[href]');
|
||||||
|
for (var mIdx = 0; mIdx < as.length; mIdx++) {
|
||||||
|
var aMac = parseUrl(as[mIdx].getAttribute('href'));
|
||||||
|
if (!aMac || aMac.host !== cur.host) continue;
|
||||||
|
if (aMac.pathname.replace(/\d+/g, '#') !== curSkel) continue;
|
||||||
|
var n = aMac.pathname.match(/\d+/g) || [];
|
||||||
|
if (n.length !== curNums.length) continue;
|
||||||
|
var same = true;
|
||||||
|
for (var k = 0; k < n.length - 1; k++) { if (n[k] !== curNums[k]) { same = false; break; } }
|
||||||
|
if (!same) continue;
|
||||||
|
var key = normKey(aMac);
|
||||||
|
var name = (as[mIdx].textContent || as[mIdx].getAttribute('title') || '').replace(/[\uE000-\uF8FF]/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (BAD.test(name)) continue;
|
||||||
|
var e = map[key];
|
||||||
|
if (!e) { e = map[key] = { url: aMac.href, nid: parseInt(n[n.length - 1], 10), name: '' }; order.push(key); }
|
||||||
|
if (name && (!e.name || BAD.test(e.name))) e.name = name;
|
||||||
|
}
|
||||||
|
var eps = order.map(function(k) { return map[k]; });
|
||||||
|
eps.sort(function(x, y) { return x.nid - y.nid; });
|
||||||
|
maccmsEpisodes = eps.map(function(e) { return { name: e.name || ('第' + e.nid + '集'), url: e.url }; });
|
||||||
|
|
||||||
|
if (curNums.length >= 2) {
|
||||||
|
var groups = {}, gorder = [];
|
||||||
|
for (var m2 = 0; m2 < as.length; m2++) {
|
||||||
|
var b2 = parseUrl(as[m2].getAttribute('href'));
|
||||||
|
if (!b2 || b2.host !== cur.host) continue;
|
||||||
|
if (b2.pathname.replace(/\d+/g, '#') !== curSkel) continue;
|
||||||
|
var mNums = b2.pathname.match(/\d+/g) || [];
|
||||||
|
if (mNums.length !== curNums.length) continue;
|
||||||
|
var ok = true;
|
||||||
|
for (var k2 = 0; k2 < mNums.length - 2; k2++) { if (mNums[k2] !== curNums[k2]) { ok = false; break; } }
|
||||||
|
if (!ok) continue;
|
||||||
|
var sid = mNums[mNums.length - 2];
|
||||||
|
var g = groups[sid];
|
||||||
|
if (!g) { g = groups[sid] = { sid: sid, map: {}, order: [] }; gorder.push(sid); }
|
||||||
|
var key2 = normKey(b2);
|
||||||
|
var nm = (as[m2].textContent || as[m2].getAttribute('title') || '').replace(/[\uE000-\uF8FF]/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (BAD.test(nm)) continue;
|
||||||
|
var ge = g.map[key2];
|
||||||
|
if (!ge) { ge = g.map[key2] = { url: b2.href, nid: parseInt(mNums[mNums.length - 1], 10), name: '' }; g.order.push(key2); }
|
||||||
|
if (nm && (!ge.name || BAD.test(ge.name))) ge.name = nm;
|
||||||
|
}
|
||||||
|
if (gorder.length > 1) {
|
||||||
|
for (var s2 = 0; s2 < gorder.length; s2++) {
|
||||||
|
var gg = groups[gorder[s2]];
|
||||||
|
var ges = gg.order.map(function(k) { return gg.map[k]; });
|
||||||
|
ges.sort(function(x, y) { return x.nid - y.nid; });
|
||||||
|
maccmsSources.push({
|
||||||
|
name: '线路' + (s2 + 1),
|
||||||
|
episodes: ges.map(function(e) { return { name: e.name || ('第' + e.nid + '集'), url: e.url }; })
|
||||||
|
});
|
||||||
|
if (gorder[s2] === curNums[curNums.length - 2]) maccmsSourceIndex = s2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 策略 4: 路径前缀同构嗅探(针对 /play/xxx、/v/xxx 等非纯数字路由兜底)
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
var pathEpisodes = [];
|
||||||
|
if (domSources.length === 0 && b64Episodes.length === 0 && maccmsEpisodes.length === 0) {
|
||||||
|
var lastSlash = cur.pathname.lastIndexOf('/');
|
||||||
|
if (lastSlash > 0) {
|
||||||
|
var dirPrefix = cur.pathname.substring(0, lastSlash + 1).toLowerCase();
|
||||||
|
var allPageA = document.querySelectorAll('a[href]');
|
||||||
|
var pMap = {}, pOrder = [];
|
||||||
|
for (var pi = 0; pi < allPageA.length; pi++) {
|
||||||
|
var pEl = allPageA[pi];
|
||||||
|
var pParsed = parseUrl(pEl.getAttribute('href'));
|
||||||
|
if (!pParsed || pParsed.host !== cur.host) continue;
|
||||||
|
var pPath = pParsed.pathname.toLowerCase();
|
||||||
|
if (pPath.indexOf(dirPrefix) !== 0) continue;
|
||||||
|
var slug = pParsed.pathname.substring(dirPrefix.length).replace(/\/+$/, '');
|
||||||
|
if (!slug || slug.indexOf('/') >= 0) continue;
|
||||||
|
var pText = ((pEl.textContent || '').replace(/\s+/g, ' ').trim() ||
|
||||||
|
pEl.getAttribute('title') ||
|
||||||
|
pEl.getAttribute('data-episode') ||
|
||||||
|
pEl.getAttribute('data-num') ||
|
||||||
|
pEl.getAttribute('data-index') || '').trim();
|
||||||
|
if (BAD.test(pText)) continue;
|
||||||
|
if (!pEl.getAttribute('data-episode') && !/^第?\s*\d+\s*[集话回期]?$/.test(pText) && !/^\d+$/.test(pText)) continue;
|
||||||
|
var pKey = normKey(pParsed);
|
||||||
|
if (!pMap[pKey]) {
|
||||||
|
pMap[pKey] = {
|
||||||
|
url: pParsed.href,
|
||||||
|
name: cleanName(pText, pOrder.length + 1),
|
||||||
|
isCurrent: (pKey === curKey)
|
||||||
|
};
|
||||||
|
pOrder.push(pKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pOrder.length >= 2) {
|
||||||
|
pathEpisodes = pOrder.map(function(k) { return pMap[k]; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 综合决策:选择最佳选集与线路数据
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
var finalEpisodes = [];
|
||||||
|
var finalSources = [];
|
||||||
|
var finalSourceIndex = -1;
|
||||||
|
|
||||||
|
var TAB_SEL = [
|
||||||
|
'.anthology-tab a', '.play-source a', '.play-source-tab a',
|
||||||
|
'.module-tab-item', '.vod-playerUrl',
|
||||||
|
'.stui-pannel__head .nav-tabs li a', '.myui-panel__head .nav-tabs li a',
|
||||||
|
'.playlist-tab li', '.nav-tabs li a', '.tab-item',
|
||||||
|
'.play_source_tab a', '.source-tab a', '[class*="tab"] a'
|
||||||
|
];
|
||||||
|
function getTabNames(count) {
|
||||||
|
for (var t = 0; t < TAB_SEL.length; t++) {
|
||||||
|
var tabs = document.querySelectorAll(TAB_SEL[t]);
|
||||||
|
if (tabs.length === count) {
|
||||||
|
var names = [];
|
||||||
|
for (var q = 0; q < tabs.length; q++) {
|
||||||
|
var c = tabs[q].cloneNode(true);
|
||||||
|
var bad = c.querySelectorAll('.badge, .tag, sup, small, .count');
|
||||||
|
for (var z = 0; z < bad.length; z++) bad[z].remove();
|
||||||
|
names.push((c.textContent || '').replace(/[\uE000-\uF8FF]/g, '').replace(/\s+/g, ' ').trim());
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domSources.length > 0) {
|
||||||
|
var chosen = (activeDomIndex >= 0) ? domSources[activeDomIndex] : domSources[0];
|
||||||
|
finalEpisodes = chosen.episodes;
|
||||||
|
if (domSources.length > 1) {
|
||||||
|
var tabNames = getTabNames(domSources.length);
|
||||||
|
for (var ds = 0; ds < domSources.length; ds++) {
|
||||||
|
finalSources.push({
|
||||||
|
name: (tabNames && tabNames[ds]) || ('线路' + (ds + 1)),
|
||||||
|
episodes: domSources[ds].episodes.map(function(e) { return { name: e.name, url: e.url }; })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finalSourceIndex = (activeDomIndex >= 0) ? activeDomIndex : 0;
|
||||||
|
}
|
||||||
|
} else if (b64Episodes.length > 0) {
|
||||||
|
finalEpisodes = b64Episodes;
|
||||||
|
} else if (maccmsEpisodes.length > 0) {
|
||||||
|
finalEpisodes = maccmsEpisodes;
|
||||||
|
if (maccmsSources.length > 1) {
|
||||||
|
var tNames = getTabNames(maccmsSources.length);
|
||||||
|
if (tNames) {
|
||||||
|
for (var ms = 0; ms < maccmsSources.length; ms++) {
|
||||||
|
maccmsSources[ms].name = tNames[ms] || maccmsSources[ms].name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalSources = maccmsSources;
|
||||||
|
finalSourceIndex = maccmsSourceIndex;
|
||||||
|
}
|
||||||
|
} else if (pathEpisodes.length > 0) {
|
||||||
|
finalEpisodes = pathEpisodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (b64Episodes.length > finalEpisodes.length) {
|
||||||
|
finalEpisodes = b64Episodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
// 标题与剧集名清洗
|
||||||
|
// -------------------------------------------------------------
|
||||||
|
var seriesTitle = '';
|
||||||
|
var episodeName = '';
|
||||||
|
|
||||||
|
if (pd) {
|
||||||
|
var vd = pd.vod_data || {};
|
||||||
|
seriesTitle = vd.vod_name || pd.vod_title || '';
|
||||||
|
episodeName = pd.vod_title_name || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seriesTitle) {
|
||||||
|
var h1 = document.querySelector('h1.title, h1.video-title, h1.anthology-title, .caption h1, h1');
|
||||||
|
if (h1) seriesTitle = (h1.textContent || '').trim();
|
||||||
|
}
|
||||||
|
if (!seriesTitle) {
|
||||||
|
var og = document.querySelector('meta[property="og:title"]');
|
||||||
|
if (og) seriesTitle = (og.getAttribute('content') || '').trim();
|
||||||
|
}
|
||||||
|
if (!seriesTitle) {
|
||||||
|
seriesTitle = (document.title || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var bookMatch = seriesTitle.match(/《([^》]+)》/);
|
||||||
|
if (bookMatch) {
|
||||||
|
seriesTitle = bookMatch[1].trim();
|
||||||
|
} else {
|
||||||
|
seriesTitle = seriesTitle.replace(/[-_\|][\s\S]*$/, '').trim();
|
||||||
|
seriesTitle = seriesTitle.replace(/第?\s*\d+\s*[集话回期]?/g, '').replace(/在线[观播]看/g, '').replace(/高清[完整版]*$/g, '').trim();
|
||||||
|
}
|
||||||
|
seriesTitle = seriesTitle.replace(/(-4k影视|-厂长资源|-极速影院|-电影网|-在线播放)$/i, '').trim();
|
||||||
|
|
||||||
|
if (!episodeName && finalEpisodes.length > 0) {
|
||||||
|
for (var fe = 0; fe < finalEpisodes.length; fe++) {
|
||||||
|
if (finalEpisodes[fe].isCurrent) {
|
||||||
|
episodeName = finalEpisodes[fe].name;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!episodeName) {
|
||||||
|
var activeEl = document.querySelector('.anthology-list .active, .playlist .active, a.active, a.current, [aria-current="page"], [aria-selected="true"]');
|
||||||
|
if (activeEl) {
|
||||||
|
var aRaw = ((activeEl.textContent || '').replace(/\s+/g, ' ').trim() || activeEl.getAttribute('data-episode') || activeEl.getAttribute('data-num') || '').trim();
|
||||||
|
episodeName = cleanName(aRaw);
|
||||||
|
} else {
|
||||||
|
var epTitleMatch = document.title.match(/第\s*(\d+)\s*[集话回期]/);
|
||||||
|
if (epTitleMatch) {
|
||||||
|
var epN = parseInt(epTitleMatch[1], 10);
|
||||||
|
episodeName = '第' + (epN < 10 ? '0' + epN : epN) + '集';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下一集推导
|
||||||
|
var nextUrl = '';
|
||||||
|
if (pd && pd.link_next) {
|
||||||
|
var pNext = parseUrl(pd.link_next);
|
||||||
|
if (pNext) nextUrl = pNext.href;
|
||||||
|
}
|
||||||
|
if (!nextUrl && finalEpisodes.length > 0) {
|
||||||
|
var cIdx = -1;
|
||||||
|
for (var f = 0; f < finalEpisodes.length; f++) {
|
||||||
|
if (finalEpisodes[f].isCurrent) {
|
||||||
|
cIdx = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
var fUrl = parseUrl(finalEpisodes[f].url);
|
||||||
|
if (fUrl && (normKey(fUrl) === curKey || (!fUrl.search && !cur.search && (fUrl.pathname || '').replace(/\/+$/, '').toLowerCase() === curPath))) {
|
||||||
|
cIdx = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cIdx >= 0 && cIdx + 1 < finalEpisodes.length) {
|
||||||
|
nextUrl = finalEpisodes[cIdx + 1].url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var adapterTag = pd ? 'maccms' : (b64Episodes.length > 0 ? '4kcz' : (domSources.length > 0 ? (cur.host.indexOf('4kvm') >= 0 ? '4kvm' : 'dom') : 'path'));
|
||||||
|
|
||||||
|
var info = {
|
||||||
|
adapter: adapterTag,
|
||||||
|
title: seriesTitle,
|
||||||
|
episodeName: episodeName,
|
||||||
|
current: cur.href,
|
||||||
|
next: nextUrl,
|
||||||
|
episodes: finalEpisodes.map(function(e) { return { name: e.name, url: e.url }; }),
|
||||||
|
sources: finalSources,
|
||||||
|
sourceIndex: finalSourceIndex
|
||||||
|
};
|
||||||
|
|
||||||
|
if (info.episodes.length > 0) {
|
||||||
|
window.AndroidVideoBridge.onSiteInfo(JSON.stringify(info));
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网页 DOM 视频嗅探 JavaScript:
|
||||||
|
* 1. 递归查找主文档与各 <iframe> 内的 video 标签真实源地址
|
||||||
|
* 2. 解析 application/ld+json 中的 contentUrl
|
||||||
|
* 3. 找到后通过 AndroidVideoBridge 接口通知原生层
|
||||||
|
*/
|
||||||
|
val JS_SNIFF_VIDEO = """
|
||||||
|
(function() {
|
||||||
|
try {
|
||||||
|
function reportVideo(src) {
|
||||||
|
if (!src || typeof src !== 'string') return;
|
||||||
|
src = src.trim();
|
||||||
|
if (src.indexOf('http://') === 0 || src.indexOf('https://') === 0) {
|
||||||
|
if (window.AndroidVideoBridge) {
|
||||||
|
window.AndroidVideoBridge.onVideoFound(src, document.title || '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findVideos(doc) {
|
||||||
|
if (!doc) return;
|
||||||
|
var videos = doc.querySelectorAll('video');
|
||||||
|
for (var i = 0; i < videos.length; i++) {
|
||||||
|
var v = videos[i];
|
||||||
|
var s = v.currentSrc || v.src;
|
||||||
|
if (s) reportVideo(s);
|
||||||
|
var sources = v.querySelectorAll('source');
|
||||||
|
for (var j = 0; j < sources.length; j++) {
|
||||||
|
var ss = sources[j].src;
|
||||||
|
if (ss) reportVideo(ss);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var iframes = doc.querySelectorAll('iframe');
|
||||||
|
for (var k = 0; k < iframes.length; k++) {
|
||||||
|
try {
|
||||||
|
findVideos(iframes[k].contentDocument);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findVideos(document);
|
||||||
|
|
||||||
|
// 监听后续动态添加或加载的视频
|
||||||
|
try {
|
||||||
|
new MutationObserver(function() {
|
||||||
|
findVideos(document);
|
||||||
|
}).observe(document.documentElement || document.body, { childList: true, subtree: true });
|
||||||
|
} catch(e) {}
|
||||||
|
} catch(e) {}
|
||||||
|
})();
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可播放的视频源:除地址外还带上「来源页」和「请求头」。
|
||||||
|
* 很多影视站的 m3u8 会校验 Referer / UA / Cookie,原生播放器必须原样带上,否则会 403。
|
||||||
|
*/
|
||||||
|
data class VideoSource(
|
||||||
|
val url: String,
|
||||||
|
val title: String = "",
|
||||||
|
/** 视频所在的网页(剧集播放页),用于断点续播 key、重新解析、选集定位 */
|
||||||
|
val pageUrl: String? = null,
|
||||||
|
/** 播放时要带的请求头(User-Agent / Referer / Cookie 等) */
|
||||||
|
val headers: Map<String, String> = emptyMap(),
|
||||||
|
/** 是否确定为 HLS(有些 m3u8 地址没有 .m3u8 后缀,需要显式告诉播放器) */
|
||||||
|
val isHls: Boolean = url.contains(".m3u8", ignoreCase = true)
|
||||||
|
) {
|
||||||
|
fun headersBundle(): Bundle = Bundle().apply { headers.forEach { (k, v) -> putString(k, v) } }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun headersFromBundle(bundle: Bundle?): Map<String, String> {
|
||||||
|
if (bundle == null) return emptyMap()
|
||||||
|
val map = LinkedHashMap<String, String>()
|
||||||
|
for (key in bundle.keySet()) {
|
||||||
|
bundle.getString(key)?.let { map[key] = it }
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 剧集 */
|
||||||
|
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>()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.optJSONObject(i) ?: continue
|
||||||
|
val eps = parseEpisodes(o.optJSONArray("episodes"))
|
||||||
|
if (eps.isNotEmpty()) out.add(PlaySource(o.optString("name").ifBlank { "线路${i + 1}" }, eps))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseEpisodes(arr: JSONArray?): List<Episode> {
|
||||||
|
val eps = mutableListOf<Episode>()
|
||||||
|
if (arr == null) return eps
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val e = arr.optJSONObject(i) ?: continue
|
||||||
|
val url = e.optString("url")
|
||||||
|
if (url.isNotBlank()) eps.add(Episode(e.optString("name").ifBlank { "第${i + 1}集" }, url))
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点适配器从网页中提取出的结构化信息(片名、当前集、选集列表、下一集、播放线路)
|
||||||
|
*/
|
||||||
|
data class SiteInfo(
|
||||||
|
val adapter: String,
|
||||||
|
val title: String,
|
||||||
|
val episodeName: String,
|
||||||
|
val current: String,
|
||||||
|
val next: String,
|
||||||
|
val episodes: List<Episode>,
|
||||||
|
/** 全部播放线路(只有一条线路时为空) */
|
||||||
|
val sources: List<PlaySource> = emptyList(),
|
||||||
|
/** 当前页属于第几条线路,-1 表示未知 */
|
||||||
|
val sourceIndex: Int = -1
|
||||||
|
) {
|
||||||
|
/** 当前播放页在选集列表中的下标,找不到返回 -1 */
|
||||||
|
fun currentIndex(pageUrl: String? = current): Int {
|
||||||
|
val key = normalizeUrl(pageUrl ?: return -1)
|
||||||
|
return episodes.indexOfFirst { normalizeUrl(it.url) == key }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展示用标题:片名 + 集名,如「沧元图 第01集」 */
|
||||||
|
fun displayTitle(): String {
|
||||||
|
val ep = episodeName.trim()
|
||||||
|
if (ep.isEmpty() || title.contains(ep)) return title
|
||||||
|
val epText = if (ep.all { it.isDigit() }) "第${ep.padStart(2, '0')}集" else ep
|
||||||
|
return "$title $epText"
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromJson(json: String?): SiteInfo? {
|
||||||
|
if (json.isNullOrBlank()) return null
|
||||||
|
return try {
|
||||||
|
val o = JSONObject(json)
|
||||||
|
val eps = PlaySource.parseEpisodes(o.optJSONArray("episodes"))
|
||||||
|
val sources = PlaySource.parseArray(o.optJSONArray("sources"))
|
||||||
|
SiteInfo(
|
||||||
|
adapter = o.optString("adapter"),
|
||||||
|
title = o.optString("title"),
|
||||||
|
episodeName = o.optString("episodeName"),
|
||||||
|
current = o.optString("current"),
|
||||||
|
next = o.optString("next"),
|
||||||
|
episodes = eps,
|
||||||
|
sources = if (sources.size > 1) sources else emptyList(),
|
||||||
|
sourceIndex = if (sources.size > 1) o.optInt("sourceIndex", -1) else -1
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去掉 #hash 与末尾斜杠,用于比较两个页面地址是否为同一集 */
|
||||||
|
fun normalizeUrl(url: String): String =
|
||||||
|
url.substringBefore('#').trimEnd('/').lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import com.tv.browser.databinding.ActivityVideoWebBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新页面播放:用 WebView 加载仅含 <video src="url"> 的简单页,用于直链或 contentUrl。
|
||||||
|
*/
|
||||||
|
class VideoWebActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
val 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun escapeHtmlAttr(s: String): String {
|
||||||
|
return s.replace("&", "&")
|
||||||
|
.replace("\"", """)
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_VIDEO_URL = "video_url"
|
||||||
|
const val EXTRA_BASE_URL = "base_url"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package com.tv.browser
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.webkit.CookieManager
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/** 一条聚合搜索结果 */
|
||||||
|
data class VodResult(
|
||||||
|
val name: String,
|
||||||
|
val siteName: String,
|
||||||
|
val host: String,
|
||||||
|
val detailUrl: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨站聚合搜片:在用户收藏的影视站里同时搜索同一个片名。
|
||||||
|
*
|
||||||
|
* 绝大多数盗版影视站都基于苹果 CMS(MacCMS),它的搜索页通常要验证码,
|
||||||
|
* 但联想接口 `/index.php/ajax/suggest?mid=1&wd=关键词` 不需要验证码,返回 JSON:
|
||||||
|
* `{code:1, list:[{id, name, en, pic}]}`。拿到 id 后再拼出详情页地址。
|
||||||
|
*
|
||||||
|
* 详情页地址各站伪静态规则不同(/voddetail/123.html、/detail/123.html …),
|
||||||
|
* 首次搜索某站时抓一次首页,找出一个详情页链接推断模板并缓存;找不到时用 MacCMS 动态路由。
|
||||||
|
* 非苹果 CMS 站点接口不存在 / 返回非 JSON,直接忽略。
|
||||||
|
*/
|
||||||
|
class VodSearchHelper(context: Context) {
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
/** 每个站点搜完回调一次(主线程),[results] 为该站结果 */
|
||||||
|
fun onSiteDone(site: String, results: List<VodResult>, done: Int, total: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
private val prefs = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
private var executor: ExecutorService? = null
|
||||||
|
@Volatile private var generation = 0
|
||||||
|
|
||||||
|
/** 收藏里去重后的站点(按域名),返回 (origin, 站点名) */
|
||||||
|
fun collectSites(bookmarks: List<Bookmark>): List<Pair<String, String>> {
|
||||||
|
val seen = LinkedHashMap<String, Pair<String, String>>()
|
||||||
|
for (b in bookmarks) {
|
||||||
|
val uri = runCatching { Uri.parse(b.url.trim()) }.getOrNull() ?: continue
|
||||||
|
val scheme = uri.scheme?.lowercase() ?: continue
|
||||||
|
if (scheme != "http" && scheme != "https") continue
|
||||||
|
val host = uri.host?.lowercase() ?: continue
|
||||||
|
if (host in SKIP_HOSTS || SKIP_HOSTS.any { host.endsWith(".$it") }) continue
|
||||||
|
val origin = "$scheme://${uri.authority}"
|
||||||
|
if (!seen.containsKey(host)) seen[host] = origin to b.title.ifBlank { host }
|
||||||
|
}
|
||||||
|
return seen.values.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun search(keyword: String, sites: List<Pair<String, String>>, userAgent: String, listener: Listener) {
|
||||||
|
cancel()
|
||||||
|
val gen = ++generation
|
||||||
|
val pool = Executors.newFixedThreadPool(THREADS).also { executor = it }
|
||||||
|
val total = sites.size
|
||||||
|
var done = 0
|
||||||
|
val cookies = CookieManager.getInstance()
|
||||||
|
for ((origin, siteName) in sites) {
|
||||||
|
val cookie = runCatching { cookies.getCookie(origin) }.getOrNull()
|
||||||
|
pool.execute {
|
||||||
|
val results = runCatching { searchSite(origin, siteName, keyword, userAgent, cookie) }
|
||||||
|
.onFailure { Log.d(TAG, "$origin failed: ${it.message}") }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
mainHandler.post {
|
||||||
|
if (gen != generation) return@post
|
||||||
|
done++
|
||||||
|
listener.onSiteDone(siteName, results, done, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pool.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
generation++
|
||||||
|
executor?.shutdownNow()
|
||||||
|
executor = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun searchSite(
|
||||||
|
origin: String, siteName: String, keyword: String, ua: String, cookie: String?
|
||||||
|
): List<VodResult> {
|
||||||
|
val kw = URLEncoder.encode(keyword, "UTF-8")
|
||||||
|
val body = httpGet("$origin/index.php/ajax/suggest?mid=1&wd=$kw&limit=30", ua, cookie, origin)
|
||||||
|
?: return emptyList()
|
||||||
|
val json = runCatching { JSONObject(body.trim()) }.getOrNull() ?: return emptyList()
|
||||||
|
val list = json.optJSONArray("list") ?: return emptyList()
|
||||||
|
if (list.length() == 0) return emptyList()
|
||||||
|
val host = Uri.parse(origin).host.orEmpty()
|
||||||
|
val sample = list.optJSONObject(0)
|
||||||
|
val template = detailTemplate(
|
||||||
|
origin, host, ua, cookie,
|
||||||
|
sample?.optString("id").orEmpty(), sample?.optString("name").orEmpty().trim()
|
||||||
|
)
|
||||||
|
val out = ArrayList<VodResult>()
|
||||||
|
for (i in 0 until list.length()) {
|
||||||
|
val o = list.optJSONObject(i) ?: continue
|
||||||
|
val id = o.optString("id").ifBlank { o.optString("vod_id") }
|
||||||
|
val name = o.optString("name").ifBlank { o.optString("vod_name") }.trim()
|
||||||
|
if (id.isBlank() || name.isBlank()) continue
|
||||||
|
val path = template.replace("{id}", id)
|
||||||
|
out.add(VodResult(name, siteName, host, if (path.startsWith("http")) path else origin + path))
|
||||||
|
}
|
||||||
|
Log.d(TAG, "$origin -> ${out.size} results (tpl=$template)")
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推断详情页地址模板,如 `/voddetail/{id}.html`;结果按域名缓存。
|
||||||
|
* 候选:首页里找到的本站详情链接 + 常见伪静态规则,逐个用 [sampleId] 实际请求验证
|
||||||
|
* (返回 2xx 且页面里出现片名 [sampleName] 才算对)。
|
||||||
|
*/
|
||||||
|
private fun detailTemplate(
|
||||||
|
origin: String, host: String, ua: String, cookie: String?, sampleId: String, sampleName: String
|
||||||
|
): String {
|
||||||
|
prefs.getString("tpl_$host", null)?.let { return it }
|
||||||
|
val candidates = LinkedHashSet<String>()
|
||||||
|
val html = runCatching { httpGet("$origin/", ua, cookie, origin, ajax = false) }.getOrNull()
|
||||||
|
if (html != null) {
|
||||||
|
// 第一个匹配可能是广告外链,逐个找本站链接
|
||||||
|
for (m in DETAIL_HREF.findAll(html)) {
|
||||||
|
var href = m.groupValues[1]
|
||||||
|
if (href.startsWith("//")) href = Uri.parse(origin).scheme + ":" + href
|
||||||
|
if (href.startsWith("http")) {
|
||||||
|
val u = runCatching { Uri.parse(href) }.getOrNull() ?: continue
|
||||||
|
if (u.host != host) continue
|
||||||
|
href = u.path.orEmpty()
|
||||||
|
}
|
||||||
|
if (!href.startsWith("/")) continue
|
||||||
|
val last = Regex("\\d+").findAll(href).lastOrNull() ?: continue
|
||||||
|
candidates.add(href.replaceRange(last.range, "{id}"))
|
||||||
|
if (candidates.size >= 2) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates.addAll(COMMON_TEMPLATES)
|
||||||
|
Log.d(TAG, "$host home=${html?.length ?: -1} candidates=$candidates")
|
||||||
|
|
||||||
|
for (tpl in candidates) {
|
||||||
|
val url = origin + tpl.replace("{id}", sampleId)
|
||||||
|
val page = runCatching { httpGet(url, ua, cookie, origin, ajax = false) }.getOrNull() ?: continue
|
||||||
|
if (if (sampleName.isNotEmpty()) page.contains(sampleName) else page.length > 2000) {
|
||||||
|
prefs.edit().putString("tpl_$host", tpl).apply()
|
||||||
|
Log.d(TAG, "$host template verified: $tpl")
|
||||||
|
return tpl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 都没验证通过(可能是网络问题),不缓存,下次再试
|
||||||
|
return candidates.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun httpGet(url: String, ua: String, cookie: String?, referer: String, ajax: Boolean = true): String? {
|
||||||
|
val conn = URL(url).openConnection() as HttpURLConnection
|
||||||
|
try {
|
||||||
|
conn.connectTimeout = TIMEOUT_MS
|
||||||
|
conn.readTimeout = TIMEOUT_MS
|
||||||
|
conn.instanceFollowRedirects = true
|
||||||
|
conn.setRequestProperty("User-Agent", ua)
|
||||||
|
conn.setRequestProperty("Referer", "$referer/")
|
||||||
|
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 bytes = conn.inputStream.use { input ->
|
||||||
|
// 首页可能很大,最多读 1MB
|
||||||
|
val buf = java.io.ByteArrayOutputStream()
|
||||||
|
val chunk = ByteArray(16 * 1024)
|
||||||
|
while (buf.size() < MAX_BYTES) {
|
||||||
|
val n = input.read(chunk)
|
||||||
|
if (n < 0) break
|
||||||
|
buf.write(chunk, 0, n)
|
||||||
|
}
|
||||||
|
buf.toByteArray()
|
||||||
|
}
|
||||||
|
return String(bytes, Charsets.UTF_8)
|
||||||
|
} finally {
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "TVBrowser.VodSearch"
|
||||||
|
private const val PREFS_NAME = "tv_browser_vod_search"
|
||||||
|
private const val THREADS = 4
|
||||||
|
private const val TIMEOUT_MS = 6000
|
||||||
|
private const val MAX_BYTES = 1024 * 1024
|
||||||
|
/** 常见的苹果 CMS 详情页伪静态规则(最后一个是动态路由,伪静态未开时也能用) */
|
||||||
|
private val COMMON_TEMPLATES = listOf(
|
||||||
|
"/voddetail/{id}.html",
|
||||||
|
"/vod/detail/id/{id}.html",
|
||||||
|
"/detail/{id}.html",
|
||||||
|
"/index.php/vod/detail/id/{id}.html"
|
||||||
|
)
|
||||||
|
private val DETAIL_HREF =
|
||||||
|
Regex("href=[\"']([^\"'\\s]*?detail[^\"'\\s]*?\\d+[^\"'\\s]*?)[\"']", RegexOption.IGNORE_CASE)
|
||||||
|
/** 明显不是苹果 CMS 的大站,不浪费请求 */
|
||||||
|
private val SKIP_HOSTS = setOf(
|
||||||
|
"baidu.com", "bing.com", "google.com", "sogou.com", "so.com", "bilibili.com",
|
||||||
|
"iqiyi.com", "youku.com", "qq.com", "mgtv.com", "douban.com", "youtube.com",
|
||||||
|
"github.com", "zhihu.com", "weibo.com", "sohu.com", "163.com", "cctv.com", "le.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 结果排序:完全同名 > 以关键词开头 > 包含关键词 > 其它 */
|
||||||
|
fun score(name: String, keyword: String): Int {
|
||||||
|
val n = name.replace(" ", "").lowercase()
|
||||||
|
val k = keyword.replace(" ", "").lowercase()
|
||||||
|
return when {
|
||||||
|
n == k -> 0
|
||||||
|
n.startsWith(k) -> 1
|
||||||
|
n.contains(k) -> 2
|
||||||
|
else -> 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 94 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- TV 桌面横幅纯色背景,颜色由 values/colors.xml 中的 tv_banner_background 控制 -->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/tv_banner_background" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<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" />
|
||||||
|
<corners android:radius="4dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="@color/url_bar_bg" />
|
||||||
|
<corners android:radius="4dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</selector>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 追剧列表紧凑小卡片背景:圆角8dp,轻量精致 -->
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_focused="true">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#1E3A8A" />
|
||||||
|
<stroke android:width="2dp" android:color="#38BDF8" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#1E293B" />
|
||||||
|
<stroke android:width="1dp" android:color="#334155" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</selector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#1A202C" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="#2D3748" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_focused="true">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#FFEEEEEE" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#FFF5F5F5" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</selector>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 模拟鼠标箭头:尖在左上(0,0),遥控器控制移动与点击 -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:strokeColor="#222222"
|
||||||
|
android:strokeWidth="0.8"
|
||||||
|
android:pathData="M0,0 L8,8 L8,22 L12,22 L12,14 L24,4 L22,2 L10,12 L10,8 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 自适应图标背景:左上深蓝 → 右下紫色,与 TV 横幅一致 -->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||||
|
<gradient
|
||||||
|
android:angle="315"
|
||||||
|
android:endColor="#7C3AED"
|
||||||
|
android:startColor="#1E40AF"
|
||||||
|
android:type="linear" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#B30B1220" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="#4038BDF8" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="#4CAF50" />
|
||||||
|
<size android:width="10dp" android:height="10dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="#F44336" />
|
||||||
|
<size android:width="10dp" android:height="10dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_focused="true">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#2563EB" />
|
||||||
|
<stroke android:width="2dp" android:color="#38BDF8" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#1E293B" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</selector>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:state_focused="true">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#2563EB" />
|
||||||
|
<stroke android:width="2dp" android:color="#60A5FA" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item android:state_selected="true">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#1E3A8A" />
|
||||||
|
<stroke android:width="1dp" android:color="#3B82F6" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#0F172A" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
</selector>
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/contentRoot"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/black"
|
||||||
|
tools:context=".BrowserActivity">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/contentLayout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@color/black">
|
||||||
|
|
||||||
|
<!-- 工具栏:适配 TV 遥控器 D-pad 焦点;窄屏(手机)支持横向滑动,TV 大屏自动铺满 -->
|
||||||
|
<HorizontalScrollView
|
||||||
|
android:id="@+id/toolbarScroll"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fillViewport="true"
|
||||||
|
android:scrollbars="none"
|
||||||
|
android:overScrollMode="never"
|
||||||
|
android:background="@color/toolbar_bg"
|
||||||
|
android:clipChildren="false"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:focusable="false"
|
||||||
|
android:focusableInTouchMode="false">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingLeft="24dp"
|
||||||
|
android:paddingRight="24dp"
|
||||||
|
android:paddingTop="12dp"
|
||||||
|
android:paddingBottom="12dp"
|
||||||
|
android:background="@color/toolbar_bg"
|
||||||
|
android:clipChildren="false"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:focusable="false"
|
||||||
|
android:focusableInTouchMode="false">
|
||||||
|
|
||||||
|
<!-- ===== 仅首页显示 ===== -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnMouseMode"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="@string/card_edit"
|
||||||
|
android:minWidth="80dp" />
|
||||||
|
|
||||||
|
<!-- ===== 仅网页显示 ===== -->
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnHome"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="@string/home"
|
||||||
|
android:minWidth="80dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnRefresh"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="@string/refresh"
|
||||||
|
android:minWidth="80dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnBookmark"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="@string/bookmark"
|
||||||
|
android:minWidth="80dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnNativePlay"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="🎬 原生播放"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:minWidth="96dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- ===== 始终显示 ===== -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/remoteBtnContainer"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_marginEnd="6dp"
|
||||||
|
android:clipChildren="false"
|
||||||
|
android:clipToPadding="false">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnRemote"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="@string/remote_control_btn"
|
||||||
|
android:minWidth="80dp" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/remoteStatusDot"
|
||||||
|
android:layout_width="10dp"
|
||||||
|
android:layout_height="10dp"
|
||||||
|
android:layout_gravity="top|end"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:layout_marginEnd="2dp"
|
||||||
|
android:elevation="8dp" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnHistory"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="@string/history"
|
||||||
|
android:minWidth="80dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnMore"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="@string/more"
|
||||||
|
android:minWidth="80dp" />
|
||||||
|
|
||||||
|
<!-- ===== 地址栏 + 语音 + 前往 ===== -->
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/urlBar"
|
||||||
|
android:layout_width="180dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginLeft="16dp"
|
||||||
|
android:layout_marginRight="8dp"
|
||||||
|
android:background="@drawable/btn_focus_bg"
|
||||||
|
android:hint="@string/url_hint"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:inputType="textUri"
|
||||||
|
android:imeOptions="actionGo"
|
||||||
|
android:paddingLeft="16dp"
|
||||||
|
android:paddingRight="16dp"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textColorHint="#99FFFFFF"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="true" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnVoiceSearch"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_marginRight="8dp"
|
||||||
|
android:text="🎙️ 语音"
|
||||||
|
android:minWidth="76dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnGo"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="@string/go"
|
||||||
|
android:minWidth="80dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</HorizontalScrollView>
|
||||||
|
|
||||||
|
<!-- 首页顶部状态条:已按照用户要求完全隐藏 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/homeStatusBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvRemoteTip"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNetworkStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextClock
|
||||||
|
android:id="@+id/homeClock"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 首页追剧列表(继续观看):精简紧凑条目,确定键续播、长按移除 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutContinueWatching"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="6dp"
|
||||||
|
android:layout_marginBottom="2dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/continueHeader"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingStart="24dp"
|
||||||
|
android:paddingEnd="24dp"
|
||||||
|
android:layout_marginBottom="2dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="▶ 继续观看"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/continueHint"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="(确定键续播 · 长按移除)"
|
||||||
|
android:textColor="#64748B"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/continueList"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:clipChildren="false"
|
||||||
|
android:paddingStart="20dp"
|
||||||
|
android:paddingEnd="20dp"
|
||||||
|
android:paddingTop="1dp"
|
||||||
|
android:paddingBottom="2dp"
|
||||||
|
android:focusable="false"
|
||||||
|
android:focusableInTouchMode="false"
|
||||||
|
android:descendantFocusability="afterDescendants" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 首页:收藏夹卡片网格,不打开网址。
|
||||||
|
容器本身不可获焦(否则焦点会落到 RecyclerView 自身上,看不到高亮、需多按一次),焦点直接进入卡片 -->
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/homeGrid"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:clipChildren="false"
|
||||||
|
android:paddingStart="20dp"
|
||||||
|
android:paddingEnd="20dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:focusable="false"
|
||||||
|
android:focusableInTouchMode="false"
|
||||||
|
android:descendantFocusability="afterDescendants" />
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/webContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<com.tv.browser.RemoteWebView
|
||||||
|
android:id="@+id/webView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:focusable="true"
|
||||||
|
android:nextFocusUp="@id/urlBar" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="4dp"
|
||||||
|
android:layout_gravity="top"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:max="100"
|
||||||
|
android:progress="0" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnFloatingPlay"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_gravity="bottom|end"
|
||||||
|
android:layout_margin="24dp"
|
||||||
|
android:background="@drawable/btn_focus_bg"
|
||||||
|
android:text="🎬 检测到视频源 · 点此原生播放"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:elevation="10dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 全屏光标层:可移动到工具栏和页面,触摸穿透到下方 contentLayout -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/cursorOverlay"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:focusable="false"
|
||||||
|
android:clickable="false">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/cursorView"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:src="@drawable/cursor_pointer_red"
|
||||||
|
android:visibility="visible"
|
||||||
|
android:focusable="false"
|
||||||
|
android:clickable="false"
|
||||||
|
android:contentDescription="@string/cursor_pointer_desc" />
|
||||||
|
</FrameLayout>
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:id="@+id/playerRoot"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="#000000"
|
||||||
|
android:keepScreenOn="true">
|
||||||
|
|
||||||
|
<!-- 后台解析用的隐藏 WebView 容器(1px,被播放画面完全遮住) -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/resolverHost"
|
||||||
|
android:layout_width="1dp"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:focusable="false" />
|
||||||
|
|
||||||
|
<!-- 视频画面:ExoPlayer,不使用自带控制栏(使用下方 TV 专用 OSD) -->
|
||||||
|
<androidx.media3.ui.PlayerView
|
||||||
|
android:id="@+id/playerView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="#000000"
|
||||||
|
android:focusable="false"
|
||||||
|
app:use_controller="false"
|
||||||
|
app:resize_mode="fit"
|
||||||
|
app:shutter_background_color="#000000" />
|
||||||
|
|
||||||
|
<!-- 中间加载缓冲圆圈 -->
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/playerLoading"
|
||||||
|
android:layout_width="64dp"
|
||||||
|
android:layout_height="64dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:indeterminate="true"
|
||||||
|
android:visibility="visible" />
|
||||||
|
|
||||||
|
<!-- 解析状态提示(如:正在解析第 2 集…) -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvStatus"
|
||||||
|
android:gravity="center"
|
||||||
|
android:lineSpacingExtra="4dp"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_marginTop="72dp"
|
||||||
|
android:textColor="#CBD5E1"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- 遥控快进/快退/播放反馈提示浮层 -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvFeedback"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:background="#99000000"
|
||||||
|
android:paddingLeft="24dp"
|
||||||
|
android:paddingRight="24dp"
|
||||||
|
android:paddingTop="12dp"
|
||||||
|
android:paddingBottom="12dp"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="26sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- 暂停状态居中常驻浮层 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/pauseOverlay"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:background="@drawable/pause_overlay_bg"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="false"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="32dp"
|
||||||
|
android:paddingEnd="32dp"
|
||||||
|
android:paddingTop="18dp"
|
||||||
|
android:paddingBottom="18dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="⏸"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="38sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="已暂停 · 按确定键继续"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- TV 播放器控制 OSD 浮层(无操作 5s 后自动隐藏) -->
|
||||||
|
<RelativeLayout
|
||||||
|
android:id="@+id/playerOsd"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="#44000000"
|
||||||
|
android:padding="24dp">
|
||||||
|
|
||||||
|
<!-- 顶部控制栏 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/topBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentTop="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnBack"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:text="❮ 返回"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:minWidth="72dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvVideoTitle"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginLeft="16dp"
|
||||||
|
android:layout_marginRight="16dp"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnExternal"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:text="📺 外部播放"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:minWidth="100dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 底部控制栏与进度条 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/bottomBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentBottom="true"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<!-- 播放状态信息(网速、缓冲、剩余时长、播完预估) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="6dp"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvPlaybackInfo"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:text="网速: 0 KB/s · 缓冲: 0秒 · 剩余: --:--" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvKeyHint"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="左右快进 · 确定暂停 · 上键选集"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 进度条与时间 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvCurrentTime"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="#CBD5E1"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:text="00:00" />
|
||||||
|
|
||||||
|
<SeekBar
|
||||||
|
android:id="@+id/playerSeekBar"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginLeft="12dp"
|
||||||
|
android:layout_marginRight="12dp"
|
||||||
|
android:max="1000"
|
||||||
|
android:focusable="true" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTotalTime"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:text="--:--" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 底部按钮组(水平可滚动,防止电视小屏幕或按键超长时被裁剪) -->
|
||||||
|
<HorizontalScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:scrollbars="none"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:focusable="false">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnPlayPause"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="42dp"
|
||||||
|
android:text="暂停"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:minWidth="72dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnNext"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="42dp"
|
||||||
|
android:layout_marginLeft="12dp"
|
||||||
|
android:text="下一集 ⏭"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:minWidth="80dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnEpisodes"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="42dp"
|
||||||
|
android:layout_marginLeft="12dp"
|
||||||
|
android:text="选集"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:minWidth="72dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnSource"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="42dp"
|
||||||
|
android:layout_marginLeft="12dp"
|
||||||
|
android:maxWidth="200dp"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:text="线路"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:minWidth="72dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnSpeed"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="42dp"
|
||||||
|
android:layout_marginLeft="12dp"
|
||||||
|
android:text="1.0x"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:minWidth="72dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnRatio"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="42dp"
|
||||||
|
android:layout_marginLeft="12dp"
|
||||||
|
android:text="适应屏幕"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:minWidth="88dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnDecode"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="42dp"
|
||||||
|
android:layout_marginLeft="12dp"
|
||||||
|
android:text="解码: 自动"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:minWidth="92dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnSkip"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="42dp"
|
||||||
|
android:layout_marginLeft="12dp"
|
||||||
|
android:text="片头尾"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:minWidth="76dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnStats"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="42dp"
|
||||||
|
android:layout_marginLeft="12dp"
|
||||||
|
android:text="播放信息"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:minWidth="80dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnPip"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="42dp"
|
||||||
|
android:layout_marginLeft="12dp"
|
||||||
|
android:text="画中画"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:minWidth="76dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</HorizontalScrollView>
|
||||||
|
</LinearLayout>
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<!-- 选集面板(底部弹出,遥控器上键或「选集」按钮打开) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/episodePanel"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:background="#E6101418"
|
||||||
|
android:clickable="true"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingLeft="32dp"
|
||||||
|
android:paddingRight="32dp"
|
||||||
|
android:paddingTop="20dp"
|
||||||
|
android:paddingBottom="24dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvEpisodePanelTitle"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="12dp"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/episodeGrid"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:focusable="false"
|
||||||
|
android:descendantFocusability="afterDescendants" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="24dp"
|
||||||
|
android:minWidth="360dp">
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/scriptName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/user_script_name_hint"
|
||||||
|
android:inputType="text"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:background="@drawable/btn_focus_bg"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textColorHint="#99FFFFFF"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="true" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/scriptPattern"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:hint="@string/user_script_pattern_hint"
|
||||||
|
android:inputType="textUri"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:background="@drawable/btn_focus_bg"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textColorHint="#99FFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="true" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/scriptCode"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="120dp"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:hint="@string/user_script_code_hint"
|
||||||
|
android:inputType="textMultiLine|textNoSuggestions"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:background="@drawable/btn_focus_bg"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textColorHint="#99FFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:gravity="top"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="true" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="end"
|
||||||
|
android:layout_marginTop="20dp">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnSave"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:text="@string/user_script_save"
|
||||||
|
android:minWidth="80dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCancel"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="@string/close"
|
||||||
|
android:minWidth="80dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?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/history"
|
||||||
|
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">
|
||||||
|
|
||||||
|
<!-- 列表容器本身不可获焦,焦点只落在条目上,避免“按了没反应”的空焦点 -->
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/historyList"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:scrollbars="vertical"
|
||||||
|
android:focusable="false"
|
||||||
|
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_history"
|
||||||
|
android:textColor="#99FFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:padding="24dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="end"
|
||||||
|
android:layout_marginTop="16dp">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnClearHistory"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:text="@string/clear_history"
|
||||||
|
android:minWidth="80dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnClose"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="@string/close"
|
||||||
|
android:minWidth="80dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<?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:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/remote_control_title"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:paddingBottom="16dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/remoteStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/remote_control_connecting"
|
||||||
|
android:textColor="#99FFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:paddingBottom="8dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/remoteServerStatusRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingBottom="12dp">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:id="@+id/remoteServerStatusDot"
|
||||||
|
android:layout_width="10dp"
|
||||||
|
android:layout_height="10dp"
|
||||||
|
android:background="@drawable/remote_status_dot_running" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/remoteServerStatusText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="@string/remote_server_status_running"
|
||||||
|
android:textColor="#99FFFFFF"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnStartRemoteServer"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="36dp"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:text="@string/remote_server_start"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:minWidth="72dp"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/remoteEmulatorSection"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:paddingBottom="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/remoteEmulatorHint"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/remote_control_emulator_hint"
|
||||||
|
android:textColor="#FFB74D"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:lineSpacingExtra="2dp"
|
||||||
|
android:paddingBottom="8dp" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/remoteHostIpInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/remote_control_host_ip_hint"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:background="@drawable/btn_focus_bg"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textColorHint="#99FFFFFF"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/remoteQr"
|
||||||
|
android:layout_width="240dp"
|
||||||
|
android:layout_height="240dp"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:contentDescription="@string/remote_control_qr_desc"
|
||||||
|
android:scaleType="fitCenter"
|
||||||
|
android:background="@color/white"
|
||||||
|
android:padding="8dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/remoteUrl"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textColor="#99FFFFFF"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:ellipsize="middle" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="end">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnOpenOnTv"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:text="@string/remote_control_open_on_tv"
|
||||||
|
android:minWidth="80dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:nextFocusRight="@id/btnRestartRemoteServer" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnRestartRemoteServer"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:text="@string/remote_server_restart"
|
||||||
|
android:minWidth="80dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:nextFocusLeft="@id/btnOpenOnTv"
|
||||||
|
android:nextFocusRight="@id/btnCloseRemote" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCloseRemote"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="@string/close"
|
||||||
|
android:minWidth="80dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:nextFocusLeft="@id/btnRestartRemoteServer" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,942 @@
|
|||||||
|
<?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="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="#0F172A"
|
||||||
|
android:padding="20dp"
|
||||||
|
android:minWidth="720dp"
|
||||||
|
android:minHeight="460dp">
|
||||||
|
|
||||||
|
<!-- 顶栏标题与关闭按钮 -->
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingBottom="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_alignParentStart="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:text="⚙️ 系统与播放设置中心"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnCloseSettings"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="36dp"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:layout_centerVertical="true"
|
||||||
|
android:text="关闭 (返回)"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:paddingStart="14dp"
|
||||||
|
android:paddingEnd="14dp" />
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="#1E293B"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<!-- 主体左右双栏 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<!-- 左侧分类导航 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/sidebarTabs"
|
||||||
|
android:layout_width="190dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingEnd="12dp">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/tabPlayback"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:background="@drawable/setting_tab_bg"
|
||||||
|
android:text="🎬 播放与解码"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:paddingStart="14dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:focusable="true" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/tabBrowser"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:background="@drawable/setting_tab_bg"
|
||||||
|
android:text="🌐 浏览与搜索"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:paddingStart="14dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:focusable="true" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/tabSafety"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:background="@drawable/setting_tab_bg"
|
||||||
|
android:text="🛡️ 广告与安全"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:paddingStart="14dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:focusable="true" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/tabStorage"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:background="@drawable/setting_tab_bg"
|
||||||
|
android:text="🧹 存储与网络"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:paddingStart="14dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:focusable="true" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/tabAbout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:background="@drawable/setting_tab_bg"
|
||||||
|
android:text="ℹ️ 关于与按键"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:paddingStart="14dp"
|
||||||
|
android:layout_marginBottom="8dp"
|
||||||
|
android:focusable="true" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 纵向分割线 -->
|
||||||
|
<View
|
||||||
|
android:layout_width="1dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="#1E293B"
|
||||||
|
android:layout_marginEnd="16dp" />
|
||||||
|
|
||||||
|
<!-- 右侧卡片内容区域 -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/contentContainer"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:layout_weight="1">
|
||||||
|
|
||||||
|
<!-- 1. 播放与解码面板 -->
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/panelPlayback"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:scrollbars="vertical"
|
||||||
|
android:visibility="visible">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<!-- 自动接管播放 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardAutoTakeover"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="自动接管全屏播放"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="网页嗅探到视频流后 3 秒倒计时自动进入原生全屏播放器"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAutoTakeoverStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="已开启"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 解码兼容模式 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardDecoderMode"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="视频解码兼容模式"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="自动(推荐)/ 强制硬解 GPU / 强制软解 CPU(解决老旧盒子花屏绿屏)"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvDecoderStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="自动"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 下一集预解析 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardPreloadNext"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="下一集直链预解析"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="在播放当前集时后台提前解析下一集真实视频流,实现无缝连播"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvPreloadStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="已开启"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 净化播放页 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardPurifyPage"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="净化播放页"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="识别到影视播放页时只保留播放器、线路与选集,隐藏广告和无关内容"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvPurifyStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="已关闭"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 清除片头片尾记忆 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardClearSkips"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="重置片头片尾跳过记忆"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="清除播放时按快进键记住的所有剧集跳过片头/片尾时长"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="清空"
|
||||||
|
android:textColor="#F43F5E"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<!-- 2. 浏览与搜索面板 -->
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/panelBrowser"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:scrollbars="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<!-- 默认搜索引擎 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardSearchEngine"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="默认搜索引擎"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="地址栏直接输入文字时快捷调用的引擎,支持跨站影视聚合搜索"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvEngineStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="百度"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 浏览器标识 UA -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardUserAgent"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="浏览器标识 (User-Agent)"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="电脑桌面版 / 移动手机版切换,解决某些网站手机版不适配的问题"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvUaStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="电脑桌面版"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 网页缩放比例 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardZoom"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="网页字体与视图缩放"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="调整网页渲染比例,让远距离观看电视时字号更清晰舒适"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvZoomStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="100%"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 虚拟鼠标模式 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardMouseMode"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="遥控器虚拟鼠标模式"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="开启后遥控器方向键移动屏幕小红点指针,可点击任意复杂网页元素"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvMouseModeStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="关闭"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<!-- 3. 广告与安全面板 -->
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/panelSafety"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:scrollbars="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<!-- 网页广告拦截 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardAdBlock"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="网页广告强力拦截"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="自动拦截影视站弹出新窗口、悬浮遮罩、底部飘窗及恶意跳转"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAdBlockStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="已开启"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 不良动图与净化 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardContentFilter"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="不良动图与内容净化"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="智能屏蔽违规动图、诱导点击图片或一键进入纯净无图省流模式"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvFilterStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="强力过滤"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 用户自定义脚本 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardUserScripts"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="自定义油猴/用户脚本"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="管理注入网页的 JS 脚本,实现去除限制、个性化定制等高级功能"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="管理"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<!-- 4. 存储与网络面板 -->
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/panelStorage"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:scrollbars="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<!-- 清理缓存 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardClearCache"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="一键清理浏览器缓存"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="清理网页加载残留的临时静态文件,释放电视闪存与内存"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="清理"
|
||||||
|
android:textColor="#F43F5E"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 清空播放历史 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardClearHistory"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="清空历史与追剧记录"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="清空主页“继续观看”卡片及所有浏览历史进度"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="清空"
|
||||||
|
android:textColor="#F43F5E"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 网络连通性诊断 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardNetworkDiag"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="网络连通性与测速诊断"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="检测 WiFi/以太网、DNS 解析连通性与到视频源的网络延迟"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="开始诊断"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<!-- 5. 关于与按键面板 -->
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/panelAbout"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:scrollbars="vertical"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<!-- 应用版本 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardAboutVersion"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="TV 网页视频播放浏览器"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvVersionDetail"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="引擎: Chromium WebView + ExoPlayer Media3"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 遥控器键位说明 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardKeyGuide"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="🎮 遥控器快捷操作指南"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="• 【菜单键】:呼出快捷菜单与剧集列表\n• 【返回键】:网页后退 / 退出全屏播放 / 关闭对话框\n• 【方向键】:网页光标移动 / 播放时左右快进快退\n• 【OK 键】:激活网页点击 / 播放时暂停与播放\n• 【播放中长按快进】:记忆当前秒数为跳过片头/片尾"
|
||||||
|
android:textColor="#E2E8F0"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:lineSpacingExtra="4dp"
|
||||||
|
android:layout_marginTop="8dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 手机扫码局域网遥控 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/cardRemoteServer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/setting_card_bg"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="14dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="10dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="手机局域网遥控与推送"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="同一 WiFi 下用手机浏览器扫码控制输入与投屏"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:layout_marginTop="3dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="打开二维码"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
</FrameLayout>
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?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/user_scripts"
|
||||||
|
android:textColor="@color/white"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:paddingBottom="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/dialogHint"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/user_scripts_hint"
|
||||||
|
android:textColor="#99FFFFFF"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:paddingBottom="16dp" />
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:minHeight="120dp">
|
||||||
|
|
||||||
|
<ListView
|
||||||
|
android:id="@+id/scriptList"
|
||||||
|
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/user_script_no_scripts"
|
||||||
|
android:textColor="#99FFFFFF"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:padding="24dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
</FrameLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="end"
|
||||||
|
android:layout_marginTop="16dp">
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnAdd"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:text="@string/user_script_add"
|
||||||
|
android:minWidth="80dp" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnClose"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="@string/close"
|
||||||
|
android:minWidth="80dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- 首页追剧紧凑胶囊卡片:标题与进度并排在同一行,极致节省大屏空间 -->
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/continueCard"
|
||||||
|
android:layout_width="190dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="3dp"
|
||||||
|
android:layout_marginEnd="3dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:layout_marginBottom="2dp"
|
||||||
|
android:background="@drawable/continue_card_bg"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="false"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="10dp"
|
||||||
|
android:paddingEnd="10dp"
|
||||||
|
android:paddingTop="7dp"
|
||||||
|
android:paddingBottom="7dp">
|
||||||
|
|
||||||
|
<!-- 标题与进度同一行 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/continueTitle"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/continueSubtitle"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="6dp"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:textColor="#38BDF8"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/continueProgress"
|
||||||
|
style="?android:attr/progressBarStyleHorizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="2.5dp"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:max="100"
|
||||||
|
android:progressTint="#38BDF8"
|
||||||
|
android:progressBackgroundTint="#334155" />
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/episodeName"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_margin="5dp"
|
||||||
|
android:background="@drawable/btn_focus_bg"
|
||||||
|
android:clickable="true"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="false"
|
||||||
|
android:gravity="center"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:paddingLeft="8dp"
|
||||||
|
android:paddingRight="8dp"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp" />
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/historyItemRoot"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingStart="16dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:paddingTop="12dp"
|
||||||
|
android:paddingBottom="12dp"
|
||||||
|
android:layout_marginBottom="2dp"
|
||||||
|
android:background="@drawable/btn_focus_bg"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="false"
|
||||||
|
android:clickable="true"
|
||||||
|
android:minHeight="56dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/historyTitle"
|
||||||
|
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/historyUrl"
|
||||||
|
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>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/homeCardRoot"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="5dp"
|
||||||
|
android:padding="0dp"
|
||||||
|
android:clipChildren="false"
|
||||||
|
android:clipToPadding="false"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="false"
|
||||||
|
android:clickable="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/homeCardContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:paddingLeft="8dp"
|
||||||
|
android:paddingRight="8dp"
|
||||||
|
android:minHeight="84dp">
|
||||||
|
|
||||||
|
<!-- 站点初始首字图标徽章:精致紧凑 -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/homeCardBadge"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:layout_marginBottom="6dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/homeCardTitle"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/homeCardUrl"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="middle" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 右上角删除标记:仅在整理模式显示 -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/homeCardDelete"
|
||||||
|
android:layout_width="26dp"
|
||||||
|
android:layout_height="26dp"
|
||||||
|
android:layout_gravity="top|end"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="✕"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#FFFFFFFF"
|
||||||
|
android:background="@drawable/btn_focus_bg"
|
||||||
|
android:layout_margin="3dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:focusable="false"
|
||||||
|
android:focusableInTouchMode="false"
|
||||||
|
android:clickable="true" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/userScriptItemRoot"
|
||||||
|
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="10dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:background="@drawable/btn_focus_bg"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="true"
|
||||||
|
android:clickable="true"
|
||||||
|
android:minHeight="56dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/scriptContent"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:focusable="false">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/scriptName"
|
||||||
|
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/scriptPattern"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<Switch
|
||||||
|
android:id="@+id/scriptEnabled"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:focusable="true"
|
||||||
|
android:focusableInTouchMode="true"
|
||||||
|
android:clickable="true" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btnRemove"
|
||||||
|
style="@style/Widget.TVBrowser.ToolbarButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:text="@string/remove"
|
||||||
|
android:minWidth="64dp" />
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Android 8.0+ 自适应图标:渐变背景 + 透明前景(电视/手机桌面裁切成圆形、圆角等形状都不会切到内容) -->
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- TV 桌面图标/横幅背景色,修改此值即可更换桌面上的图标背景(勿用纯白 #FFFFFFFF 易与桌面融为一体) -->
|
||||||
|
<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="toolbar_bg">#FF1E1E1E</color>
|
||||||
|
<color name="url_bar_bg">#FF2D2D2D</color>
|
||||||
|
<color name="focus_highlight">#FF03DAC5</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">阿北</string>
|
||||||
|
<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>
|
||||||
|
<string name="remote_control_connecting">手机与电视连接同一 WiFi 后,扫码打开网页即可遥控</string>
|
||||||
|
<string name="remote_control_qr_desc">扫码连接</string>
|
||||||
|
<string name="remote_control_btn">手机遥控</string>
|
||||||
|
<string name="remote_control_emulator_hint">模拟器调试:\n1. 在电脑终端执行:adb forward tcp:8767 tcp:8766\n2. 电脑浏览器访问:http://127.0.0.1:8767\n3. 手机扫码需输入电脑 IP,端口为 8767</string>
|
||||||
|
<string name="remote_control_host_ip_hint">例如 192.168.1.100</string>
|
||||||
|
<string name="remote_control_open_on_tv">在电视上打开</string>
|
||||||
|
<string name="remote_server_status_running">服务状态:运行中</string>
|
||||||
|
<string name="remote_server_status_stopped">服务状态:已停止</string>
|
||||||
|
<string name="remote_server_start">启动服务</string>
|
||||||
|
<string name="remote_server_restart">重启服务</string>
|
||||||
|
<string name="check_network">检查网络</string>
|
||||||
|
<string name="network_connected">网络已连接 (%s)</string>
|
||||||
|
<string name="network_disconnected">网络未连接</string>
|
||||||
|
<string name="network_type_wifi">WiFi</string>
|
||||||
|
<string name="network_type_ethernet">有线网络</string>
|
||||||
|
<string name="network_type_unknown">未知类型</string>
|
||||||
|
<string name="network_connected_not_validated">网络已连接 (%s),但系统未验证互联网</string>
|
||||||
|
<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>
|
||||||
|
<string name="user_script_name_hint">脚本名称</string>
|
||||||
|
<string name="user_script_pattern_hint">网址匹配(* 表示全部)</string>
|
||||||
|
<string name="user_script_code_hint">JavaScript 代码</string>
|
||||||
|
<string name="user_script_save">保存</string>
|
||||||
|
<string name="user_script_no_scripts">暂无脚本,点击「添加脚本」添加</string>
|
||||||
|
<string name="user_script_enabled">启用</string>
|
||||||
|
<string name="user_script_saved">脚本已保存</string>
|
||||||
|
<string name="more">更多</string>
|
||||||
|
<string name="cache_cleared">缓存已清除</string>
|
||||||
|
<string name="history">历史记录</string>
|
||||||
|
<string name="no_history">暂无浏览历史</string>
|
||||||
|
<string name="clear_history">清空历史</string>
|
||||||
|
<string name="history_cleared">历史记录已清空</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- 必须继承 AppCompat 主题,否则 AppCompatActivity 会崩溃 -->
|
||||||
|
<style name="Theme.TVBrowser" parent="Theme.AppCompat.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/black</item>
|
||||||
|
<item name="android:colorPrimary">@color/primary</item>
|
||||||
|
<item name="android:colorPrimaryDark">@color/primary_dark</item>
|
||||||
|
<item name="android:colorAccent">@color/accent</item>
|
||||||
|
<item name="android:windowNoTitle">true</item>
|
||||||
|
</style>
|
||||||
|
<style name="Theme.TVBrowser.NoActionBar" parent="Theme.TVBrowser" />
|
||||||
|
<style name="Widget.TVBrowser.ToolbarButton" parent="Widget.AppCompat.Button">
|
||||||
|
<item name="android:background">@drawable/btn_focus_bg</item>
|
||||||
|
<item name="android:textColor">@color/white</item>
|
||||||
|
<item name="android:minWidth">80dp</item>
|
||||||
|
<item name="android:minHeight">48dp</item>
|
||||||
|
<item name="android:layout_marginEnd">6dp</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<external-files-path name="uploads" path="uploads" />
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
</paths>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.2.2"
|
||||||
|
kotlin = "1.9.22"
|
||||||
|
coreKtx = "1.12.0"
|
||||||
|
appcompat = "1.6.1"
|
||||||
|
material = "1.11.0"
|
||||||
|
constraintlayout = "2.1.4"
|
||||||
|
leanback = "1.2.0-alpha04"
|
||||||
|
fragment = "1.6.2"
|
||||||
|
recyclerview = "1.3.2"
|
||||||
|
nanohttpd = "2.3.1"
|
||||||
|
zxing = "3.5.2"
|
||||||
|
media3 = "1.3.1"
|
||||||
|
webkit = "1.10.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.1.5"
|
||||||
|
espressoCore = "3.5.1"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
|
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
|
constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||||
|
leanback = { group = "androidx.leanback", name = "leanback", version.ref = "leanback" }
|
||||||
|
fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" }
|
||||||
|
recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
|
||||||
|
nanohttpd = { group = "org.nanohttpd", name = "nanohttpd", version.ref = "nanohttpd" }
|
||||||
|
nanohttpd-websocket = { group = "org.nanohttpd", name = "nanohttpd-websocket", version.ref = "nanohttpd" }
|
||||||
|
zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" }
|
||||||
|
media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||||
|
media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" }
|
||||||
|
media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" }
|
||||||
|
androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
junitVersion = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
espressoCore = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "TVBrowser"
|
||||||
|
include(":app")
|
||||||