feat(android-tv): 创建 Android TV WebView 壳应用

- 添加 TV 专用的 Leanback 主题和配置
- 支持遥控器 D-Pad 导航
- 添加 AssetReader JS 接口
- 强制横屏显示
- 处理遥控器按键(返回、菜单、信息)
This commit is contained in:
李岩岩 2026-02-05 12:41:41 +08:00
parent 327f03c562
commit 2a565fb8da
18 changed files with 522 additions and 0 deletions

101
android-tv/README.md Normal file
View File

@ -0,0 +1,101 @@
# Android TV WebView 壳
为 Android TV 设备(包括智能电视、电视盒子等)提供的 WebView 套壳应用。
## 特性
- ✅ **Leanback 支持** - 专为 TV 优化的主题和启动器
- ✅ **D-Pad 导航** - 遥控器方向键支持,自动注入 JS 导航
- ✅ **全屏沉浸** - 无状态栏/导航栏,纯全屏体验
- ✅ **横屏锁定** - 强制横屏显示
- ✅ **按键处理** - 支持返回、菜单、信息键等遥控器按键
## 目录结构
```
android-tv/
├── app/src/main/
│ ├── java/com/iptv/tv/
│ │ └── MainActivity.java # TV 主活动(含 D-Pad 支持)
│ ├── res/
│ │ ├── layout/activity_main.xml
│ │ ├── values/
│ │ │ ├── strings.xml
│ │ │ ├── styles.xml # Leanback 主题
│ │ │ └── colors.xml
│ │ └── drawable/
│ │ └── ic_banner.xml # TV 启动器横幅
│ ├── assets/
│ │ └── www/ # 打包的 Web 资源
│ └── AndroidManifest.xml # TV 特定配置
├── build.sh # 一键构建脚本
└── ...
```
## 构建步骤
```bash
# 1. 构建 Web UI
cd ../ui
npm install
npm run build
# 2. 构建 Android TV APK
cd ../android-tv
./build.sh
```
APK 输出: `app/build/outputs/apk/debug/app-debug.apk`
## TV 特定配置
### AndroidManifest.xml
```xml
<!-- TV 必需特性 -->
<uses-feature android:name="android.software.leanback" android:required="true" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<!-- TV 启动器 -->
<intent-filter>
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
```
### D-Pad 导航支持
应用自动注入 JavaScript 代码,使 Web 应用支持遥控器方向键导航:
- **方向键** - 在可聚焦元素间移动
- **确认键** - 点击当前聚焦元素
- **返回键** - 页面后退/退出应用
- **菜单键** - 刷新页面
- **信息键** - 显示版本信息
## 遥控器按键映射
| 按键 | 功能 |
|------|------|
| 上/下/左/右 | 导航焦点 |
| 确认/OK | 点击 |
| 返回 | 页面后退 |
| 菜单 | 刷新页面 |
| 信息/INFO | 显示版本 |
## 开发与调试
1. **ADB 连接 TV**
```bash
adb connect <TV_IP>:5555
adb install app-debug.apk
```
2. **查看日志**
```bash
adb logcat -s IPTV:D
```
3. **远程调试**
```bash
adb shell am start -a android.intent.action.VIEW -d "http://<dev_server>:5173"
```

View File

@ -0,0 +1,33 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.iptv.tv'
compileSdk 34
defaultConfig {
applicationId "com.iptv.tv"
minSdk 21
targetSdk 34
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
dependencies {
implementation 'androidx.leanback:leanback:1.0.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
}

1
android-tv/app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1 @@
# ProGuard rules

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.iptv.tv">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- TV 特定配置 -->
<uses-feature android:name="android.software.leanback" android:required="true" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:allowBackup="true"
android:icon="@drawable/ic_banner"
android:label="@string/app_name"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
android:banner="@drawable/ic_banner">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize"
android:screenOrientation="landscape"
android:theme="@style/AppTheme.NoActionBar">
<!-- TV Launcher -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<!-- 普通 Launcher用于调试 -->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,48 @@
package com.iptv.tv;
import android.content.Context;
import android.webkit.JavascriptInterface;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
public class AssetReader {
private Context context;
public AssetReader(Context context) {
this.context = context;
}
@JavascriptInterface
public String readFile(String path) {
try {
InputStream is = context.getAssets().open(path);
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line).append("\n");
}
reader.close();
return sb.toString();
} catch (IOException e) {
return "ERROR: " + e.getMessage();
}
}
@JavascriptInterface
public String readChannelData() {
return readFile("www/api/result.txt");
}
@JavascriptInterface
public boolean fileExists(String path) {
try {
context.getAssets().open(path).close();
return true;
} catch (IOException e) {
return false;
}
}
}

View File

@ -0,0 +1,148 @@
package com.iptv.tv;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.graphics.Color;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.view.WindowManager;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
public class MainActivity extends Activity {
private static final String TAG = "IPTV_TV";
private WebView webView;
@SuppressLint("SetJavaScriptEnabled")
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Log.d(TAG, "onCreate started");
// 全屏设置
getWindow().setFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN
);
// 设置内容视图
webView = new WebView(this);
webView.setBackgroundColor(Color.BLACK);
setContentView(webView);
// 添加 JavaScript 接口
webView.addJavascriptInterface(new AssetReader(this), "AndroidAsset");
// WebView 设置
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setDatabaseEnabled(true);
settings.setCacheMode(WebSettings.LOAD_DEFAULT);
settings.setMediaPlaybackRequiresUserGesture(false);
settings.setAllowFileAccess(true);
settings.setAllowContentAccess(true);
settings.setAllowFileAccessFromFileURLs(true);
settings.setAllowUniversalAccessFromFileURLs(true);
// TV 不需要缩放
settings.setSupportZoom(false);
settings.setBuiltInZoomControls(false);
// WebViewClient
webView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
return false;
}
@Override
public void onPageFinished(WebView view, String url) {
Log.d(TAG, "onPageFinished: " + url);
injectDPadSupport();
}
@Override
public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) {
Log.e(TAG, "onReceivedError: " + errorCode + " - " + description);
}
});
// WebChromeClient
webView.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onConsoleMessage(android.webkit.ConsoleMessage consoleMessage) {
Log.d(TAG, "Console: " + consoleMessage.message());
return true;
}
});
// 延迟加载
new Handler(Looper.getMainLooper()).postDelayed(() -> {
webView.loadUrl("file:///android_asset/www/index.html");
}, 100);
Log.d(TAG, "onCreate finished");
}
private void injectDPadSupport() {
String js = "javascript:(function() {" +
"document.addEventListener('keydown', function(e) {" +
" if(e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {" +
" e.preventDefault();" +
" var focusable = document.querySelectorAll('button, a, input, [tabindex]:not([tabindex=\"-1\"])');" +
" var current = document.activeElement;" +
" var index = Array.prototype.indexOf.call(focusable, current);" +
" if(e.key === 'ArrowRight' || e.key === 'ArrowDown') {" +
" var next = focusable[index + 1] || focusable[0];" +
" next.focus();" +
" } else {" +
" var prev = focusable[index - 1] || focusable[focusable.length - 1];" +
" prev.focus();" +
" }" +
" }" +
" if(e.key === 'Enter') {" +
" document.activeElement.click();" +
" }" +
"});" +
"})()";
webView.evaluateJavascript(js, null);
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK && webView.canGoBack()) {
webView.goBack();
return true;
}
return super.onKeyDown(keyCode, event);
}
@Override
protected void onPause() {
super.onPause();
if (webView != null) webView.onPause();
}
@Override
protected void onResume() {
super.onResume();
if (webView != null) webView.onResume();
}
@Override
protected void onDestroy() {
super.onDestroy();
if (webView != null) webView.destroy();
}
}

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#0a0a0a" />
</shape>
</item>
<item
android:left="48dp"
android:right="48dp"
android:top="20dp"
android:bottom="20dp"
android:gravity="center">
<vector
android:width="160dp"
android:height="60dp"
android:viewportWidth="160"
android:viewportHeight="60">
<path
android:fillColor="#FFFFFF"
android:pathData="M15,10 L15,50 L23,50 L23,34 L40,50 L52,50 L52,10 L40,10 L23,26 L23,10 Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M65,20 L65,25 L70,25 L70,35 L65,35 L65,40 L85,40 L85,35 L90,35 L90,25 L85,25 L85,20 Z M75,30 L80,30 L80,32 L75,32 Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M95,20 L95,40 L105,40 L105,35 L115,35 L115,40 L125,40 L125,20 L115,20 L115,25 L105,25 L105,20 Z M105,30 L115,30 L115,32 L105,32 Z"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M130,20 L130,40 L150,40 L150,32 L140,32 L140,20 Z"/>
</vector>
</item>
</layer-list>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#0a0a0a">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#0a0a0a"
android:focusable="true"
android:focusableInTouchMode="true" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">IPTV TV</string>
</resources>

View File

@ -0,0 +1,12 @@
<resources>
<style name="AppTheme" parent="Theme.Leanback">
<item name="android:windowNoTitle">true</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowBackground">@android:color/black</item>
</style>
<style name="AppTheme.NoActionBar" parent="AppTheme">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>

4
android-tv/build.gradle Normal file
View File

@ -0,0 +1,4 @@
// Top-level build file
plugins {
id 'com.android.application' version '8.1.0' apply false
}

31
android-tv/build.sh Executable file
View File

@ -0,0 +1,31 @@
#!/bin/bash
# Android TV WebView 壳打包脚本
set -e
echo "=== IPTV Android TV 构建 ==="
# 检查 UI 构建产物
if [ ! -d "../ui/dist-web" ]; then
echo "错误: 未找到 ../ui/dist-web 目录"
echo "请先构建 Web UI: cd ../ui && npm run build"
exit 1
fi
# 复制 Web 资源到 Android assets
echo "复制 Web 资源..."
mkdir -p app/src/main/assets/www
cp -r ../ui/dist-web/* app/src/main/assets/www/
# 统计文件
echo "已复制文件数量:"
find app/src/main/assets/www -type f | wc -l
# 构建 Debug APK
echo "构建 Debug APK..."
./gradlew assembleDebug
echo ""
echo "=== 构建完成 ==="
echo "APK 位置: app/build/outputs/apk/debug/app-debug.apk"

View File

@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
android.suppressUnsupportedCompileSdk=34

Binary file not shown.

View File

@ -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

17
android-tv/gradlew vendored Executable file
View File

@ -0,0 +1,17 @@
#!/bin/sh
# Gradle Wrapper Startup Script
# Find the script's directory
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WRAPPER_JAR="$SCRIPT_DIR/gradle/wrapper/gradle-wrapper.jar"
# Find Java
if [ -n "$JAVA_HOME" ] ; then
JAVA_CMD="$JAVA_HOME/bin/java"
else
JAVA_CMD="java"
fi
# Run Gradle wrapper
exec "$JAVA_CMD" -cp "$WRAPPER_JAR" org.gradle.wrapper.GradleWrapperMain "$@"

View File

@ -0,0 +1,16 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "IPTV TV"
include ':app'