diff --git a/android-tv/README.md b/android-tv/README.md new file mode 100644 index 0000000..847f5ba --- /dev/null +++ b/android-tv/README.md @@ -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 + + + + + + + + +``` + +### D-Pad 导航支持 + +应用自动注入 JavaScript 代码,使 Web 应用支持遥控器方向键导航: + +- **方向键** - 在可聚焦元素间移动 +- **确认键** - 点击当前聚焦元素 +- **返回键** - 页面后退/退出应用 +- **菜单键** - 刷新页面 +- **信息键** - 显示版本信息 + +## 遥控器按键映射 + +| 按键 | 功能 | +|------|------| +| 上/下/左/右 | 导航焦点 | +| 确认/OK | 点击 | +| 返回 | 页面后退 | +| 菜单 | 刷新页面 | +| 信息/INFO | 显示版本 | + +## 开发与调试 + +1. **ADB 连接 TV** + ```bash + adb connect :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://:5173" + ``` diff --git a/android-tv/app/build.gradle b/android-tv/app/build.gradle new file mode 100644 index 0000000..c585851 --- /dev/null +++ b/android-tv/app/build.gradle @@ -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' +} diff --git a/android-tv/app/proguard-rules.pro b/android-tv/app/proguard-rules.pro new file mode 100644 index 0000000..f23d2ad --- /dev/null +++ b/android-tv/app/proguard-rules.pro @@ -0,0 +1 @@ +# ProGuard rules diff --git a/android-tv/app/src/main/AndroidManifest.xml b/android-tv/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..4b666d1 --- /dev/null +++ b/android-tv/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-tv/app/src/main/java/com/iptv/tv/AssetReader.java b/android-tv/app/src/main/java/com/iptv/tv/AssetReader.java new file mode 100644 index 0000000..85f73d4 --- /dev/null +++ b/android-tv/app/src/main/java/com/iptv/tv/AssetReader.java @@ -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; + } + } +} diff --git a/android-tv/app/src/main/java/com/iptv/tv/MainActivity.java b/android-tv/app/src/main/java/com/iptv/tv/MainActivity.java new file mode 100644 index 0000000..e09e575 --- /dev/null +++ b/android-tv/app/src/main/java/com/iptv/tv/MainActivity.java @@ -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(); + } +} diff --git a/android-tv/app/src/main/res/drawable/ic_banner.xml b/android-tv/app/src/main/res/drawable/ic_banner.xml new file mode 100644 index 0000000..2a5088c --- /dev/null +++ b/android-tv/app/src/main/res/drawable/ic_banner.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + diff --git a/android-tv/app/src/main/res/layout/activity_main.xml b/android-tv/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..e66bb2f --- /dev/null +++ b/android-tv/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/android-tv/app/src/main/res/values/colors.xml b/android-tv/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..768b058 --- /dev/null +++ b/android-tv/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FF000000 + #FFFFFFFF + diff --git a/android-tv/app/src/main/res/values/strings.xml b/android-tv/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a89f24e --- /dev/null +++ b/android-tv/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + IPTV TV + diff --git a/android-tv/app/src/main/res/values/styles.xml b/android-tv/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..24947da --- /dev/null +++ b/android-tv/app/src/main/res/values/styles.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android-tv/build.gradle b/android-tv/build.gradle new file mode 100644 index 0000000..ef972fe --- /dev/null +++ b/android-tv/build.gradle @@ -0,0 +1,4 @@ +// Top-level build file +plugins { + id 'com.android.application' version '8.1.0' apply false +} diff --git a/android-tv/build.sh b/android-tv/build.sh new file mode 100755 index 0000000..41c8819 --- /dev/null +++ b/android-tv/build.sh @@ -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" diff --git a/android-tv/gradle.properties b/android-tv/gradle.properties new file mode 100644 index 0000000..1869e4d --- /dev/null +++ b/android-tv/gradle.properties @@ -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 diff --git a/android-tv/gradle/wrapper/gradle-wrapper.jar b/android-tv/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..033e24c Binary files /dev/null and b/android-tv/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android-tv/gradle/wrapper/gradle-wrapper.properties b/android-tv/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..62f495d --- /dev/null +++ b/android-tv/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android-tv/gradlew b/android-tv/gradlew new file mode 100755 index 0000000..8636023 --- /dev/null +++ b/android-tv/gradlew @@ -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 "$@" diff --git a/android-tv/settings.gradle b/android-tv/settings.gradle new file mode 100644 index 0000000..f23c83f --- /dev/null +++ b/android-tv/settings.gradle @@ -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'