diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..ca2b8f2 --- /dev/null +++ b/android/README.md @@ -0,0 +1,71 @@ +# Android WebView 壳 + +## 目录结构 + +``` +android/ +├── app/src/main/ +│ ├── java/com/iptv/app/ +│ │ └── MainActivity.java # WebView 主活动 +│ ├── res/ +│ │ ├── layout/activity_main.xml +│ │ ├── values/ +│ │ │ ├── strings.xml +│ │ │ ├── styles.xml +│ │ │ └── colors.xml +│ │ └── mipmap-*/ # 图标资源 +│ ├── assets/ +│ │ └── www/ # 打包的 Web 资源 (从 ui/dist-web 复制) +│ └── AndroidManifest.xml +├── build.gradle # 项目级构建配置 +├── settings.gradle +└── gradle.properties +``` + +## 构建步骤 + +### 1. 构建 Web UI + +```bash +cd ../ui +npm install +npm run build:web +``` + +### 2. 复制资源到 Android + +```bash +# 将构建好的 web 资源复制到 Android assets +cp -r ../ui/dist-web/* app/src/main/assets/www/ +``` + +### 3. 构建 APK + +```bash +./gradlew assembleDebug +``` + +APK 输出位置: `app/build/outputs/apk/debug/app-debug.apk` + +## 开发模式 + +如需连接开发服务器测试,修改 `MainActivity.java`: + +```java +private static final String LOAD_MODE = "remote"; +private static final String REMOTE_URL = "http://你的IP:5173"; +``` + +## 功能特性 + +- ✅ WebView 加载 Web UI +- ✅ 全屏无标题栏 +- ✅ 下拉刷新 +- ✅ 返回键支持页面后退 +- ✅ 视频全屏自动横屏 +- ✅ 暗色主题 + +## 权限 + +- `INTERNET` - 网络访问 +- `ACCESS_NETWORK_STATE` - 网络状态检测 diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..5219d16 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,33 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'com.iptv.app' + compileSdk 34 + + defaultConfig { + applicationId "com.iptv.app" + 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.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f23d2ad --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1 @@ +# ProGuard rules diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d8d1841 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/error.html b/android/app/src/main/assets/error.html new file mode 100644 index 0000000..c4b836c --- /dev/null +++ b/android/app/src/main/assets/error.html @@ -0,0 +1,74 @@ + + + + + + 加载错误 + + + +
⚠️
+

页面加载失败

+

无法加载应用资源,可能是以下原因:

+ +
+

可能的解决方案:

+ +
+ + diff --git a/android/app/src/main/java/com/iptv/app/AssetReader.java b/android/app/src/main/java/com/iptv/app/AssetReader.java new file mode 100644 index 0000000..3a01b97 --- /dev/null +++ b/android/app/src/main/java/com/iptv/app/AssetReader.java @@ -0,0 +1,49 @@ +package com.iptv.app; + +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 { + // path 如: "www/api/result.txt" + 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/app/src/main/java/com/iptv/app/MainActivity.java b/android/app/src/main/java/com/iptv/app/MainActivity.java new file mode 100644 index 0000000..c7ea244 --- /dev/null +++ b/android/app/src/main/java/com/iptv/app/MainActivity.java @@ -0,0 +1,231 @@ +package com.iptv.app; + +import android.annotation.SuppressLint; +import android.content.pm.ActivityInfo; +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.View; +import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowInsetsController; +import android.view.WindowManager; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import androidx.appcompat.app.AppCompatActivity; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; + +public class MainActivity extends AppCompatActivity { + + private static final String TAG = "IPTV"; + private WebView webView; + private SwipeRefreshLayout swipeRefresh; + + // 加载模式:"local" 使用本地打包的web资源,"remote" 使用远程服务器,"test" 使用测试页面 + private static final String LOAD_MODE = "local"; + + // 远程服务器地址(开发测试用) + private static final String REMOTE_URL = "http://192.168.1.100:5173"; + + @SuppressLint("SetJavaScriptEnabled") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Log.d(TAG, "onCreate started"); + + // 必须在 setContentView 之前调用 + requestWindowFeature(Window.FEATURE_NO_TITLE); + + setContentView(R.layout.activity_main); + + webView = findViewById(R.id.webview); + swipeRefresh = findViewById(R.id.swipe_refresh); + + if (webView == null) { + Log.e(TAG, "WebView is null!"); + return; + } + + // 全屏设置(在 setContentView 之后) + setFullscreen(); + + // 添加 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); + + // 支持缩放 + settings.setSupportZoom(true); + settings.setBuiltInZoomControls(true); + settings.setDisplayZoomControls(false); + + // 启用硬件加速 + webView.setLayerType(View.LAYER_TYPE_HARDWARE, null); + + // WebViewClient 处理页面跳转 + webView.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + Log.d(TAG, "shouldOverrideUrlLoading: " + request.getUrl()); + return false; // 在 WebView 内打开链接 + } + + @Override + public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) { + Log.d(TAG, "onPageStarted: " + url); + } + + @Override + public void onPageFinished(WebView view, String url) { + Log.d(TAG, "onPageFinished: " + url); + if (swipeRefresh != null) { + swipeRefresh.setRefreshing(false); + } + } + + @Override + public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { + Log.e(TAG, "onReceivedError: " + errorCode + " - " + description + " - " + failingUrl); + } + }); + + // WebChromeClient 处理全屏视频和 JS 控制台 + webView.setWebChromeClient(new WebChromeClient() { + private View customView; + private CustomViewCallback customViewCallback; + + @Override + public boolean onConsoleMessage(android.webkit.ConsoleMessage consoleMessage) { + Log.d(TAG, "WebView Console: " + consoleMessage.message() + " at " + consoleMessage.sourceId() + ":" + consoleMessage.lineNumber()); + return true; + } + + @Override + public void onProgressChanged(WebView view, int newProgress) { + Log.d(TAG, "Loading progress: " + newProgress + "%"); + } + + @Override + public void onShowCustomView(View view, CustomViewCallback callback) { + customView = view; + customViewCallback = callback; + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + } + + @Override + public void onHideCustomView() { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + if (customViewCallback != null) { + customViewCallback.onCustomViewHidden(); + } + customView = null; + } + }); + + // 下拉刷新 + if (swipeRefresh != null) { + swipeRefresh.setOnRefreshListener(() -> webView.reload()); + swipeRefresh.setColorSchemeResources(android.R.color.holo_blue_bright); + } + + // 延迟加载页面,确保 WebView 完全初始化 + new Handler(Looper.getMainLooper()).postDelayed(() -> { + loadContent(); + }, 100); + + Log.d(TAG, "onCreate finished"); + } + + private void loadContent() { + Log.d(TAG, "loadContent, mode: " + LOAD_MODE); + String url; + if ("test".equals(LOAD_MODE)) { + // 加载测试页面(红色背景) + url = "file:///android_asset/test.html"; + } else if ("local".equals(LOAD_MODE)) { + // 加载本地打包的web资源 + url = "file:///android_asset/www/index.html"; + } else { + // 加载远程服务器(开发测试) + url = REMOTE_URL; + } + Log.d(TAG, "Loading URL: " + url); + webView.loadUrl(url); + } + + private void setFullscreen() { + // 设置状态栏和导航栏颜色 + getWindow().setStatusBarColor(Color.BLACK); + getWindow().setNavigationBarColor(Color.BLACK); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + getWindow().setDecorFitsSystemWindows(false); + WindowInsetsController controller = getWindow().getInsetsController(); + if (controller != null) { + controller.hide(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars()); + controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + } + } else { + getWindow().setFlags( + WindowManager.LayoutParams.FLAG_FULLSCREEN, + WindowManager.LayoutParams.FLAG_FULLSCREEN + ); + getWindow().getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_FULLSCREEN + ); + } + } + + @Override + public void onBackPressed() { + if (webView != null && webView.canGoBack()) { + webView.goBack(); + } else { + super.onBackPressed(); + } + } + + @Override + protected void onResume() { + super.onResume(); + if (webView != null) { + webView.onResume(); + } + setFullscreen(); + } + + @Override + protected void onPause() { + super.onPause(); + if (webView != null) { + webView.onPause(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (webView != null) { + webView.destroy(); + } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..16c3960 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..3da829c --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5ed0a2d --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..768b058 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #FF000000 + #FFFFFFFF + diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..b9f789b --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #0a0a0a + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..abc6c64 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + IPTV + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..ce07cf2 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..ef972fe --- /dev/null +++ b/android/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/build.sh b/android/build.sh new file mode 100755 index 0000000..31d60aa --- /dev/null +++ b/android/build.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Android WebView 壳打包脚本 + +set -e + +echo "=== IPTV Android 构建 ===" + +# 检查 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/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..033e24c Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..62f495d --- /dev/null +++ b/android/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/gradlew b/android/gradlew new file mode 100755 index 0000000..8636023 --- /dev/null +++ b/android/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/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..8876fa8 --- /dev/null +++ b/android/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 App" +include ':app'