diff --git a/.dockerignore b/.dockerignore
index d74733c..ea31912 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -3,3 +3,6 @@
public/screenshot1.png
public/screenshot2.png
public/screenshot3.png
+
+# Android/TV native app sources are not needed in Docker image builds
+apps/
diff --git a/.github/workflows/android-tv.yml b/.github/workflows/android-tv.yml
new file mode 100644
index 0000000..6c44e09
--- /dev/null
+++ b/.github/workflows/android-tv.yml
@@ -0,0 +1,112 @@
+name: Build Android TV APK
+
+on:
+ workflow_dispatch:
+ inputs:
+ base_url:
+ description: '服务端 Base URL,不需要带 /tv,例如 https://example.com 或 http://192.168.1.10:3000'
+ required: true
+ default: 'http://192.168.1.10:3000'
+ type: string
+ app_name:
+ description: 'Android TV 应用名称'
+ required: true
+ default: 'MoonTVPlus TV'
+ type: string
+ version_name:
+ description: '版本名'
+ required: false
+ default: '1.0.0'
+ type: string
+ version_code:
+ description: '版本号,必须是整数'
+ required: false
+ default: '1'
+ type: string
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ contents: read
+ actions: read
+
+jobs:
+ build:
+ name: Build Android TV APK
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout source code
+ uses: actions/checkout@v4
+
+ - name: Setup Java
+ uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: '17'
+
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@v3
+
+ - name: Install Android SDK packages
+ run: |
+ sdkmanager "platforms;android-35" "build-tools;35.0.0"
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+ with:
+ gradle-version: '8.10.2'
+
+ - name: Prepare Android TV icon from public/logo.png
+ run: |
+ cp public/logo.png apps/android-tv/app/src/main/res/drawable/logo.png
+
+ - name: Decode Android signing keystore
+ env:
+ ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
+ run: |
+ if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
+ echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > /tmp/android-tv-release.keystore
+ else
+ echo "ANDROID_KEYSTORE_BASE64 is not set, release APK will be unsigned."
+ fi
+
+ - name: Build APK
+ working-directory: apps/android-tv
+ env:
+ BASE_URL: ${{ inputs.base_url }}
+ APP_NAME: ${{ inputs.app_name }}
+ VERSION_NAME: ${{ inputs.version_name }}
+ VERSION_CODE: ${{ inputs.version_code }}
+ ANDROID_KEYSTORE_PATH: /tmp/android-tv-release.keystore
+ ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+ ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
+ ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
+ run: |
+ gradle assembleDebug assembleRelease \
+ -PBASE_URL="$BASE_URL" \
+ -PAPP_NAME="$APP_NAME" \
+ -PVERSION_NAME="$VERSION_NAME" \
+ -PVERSION_CODE="$VERSION_CODE"
+
+ - name: Collect APK artifacts
+ run: |
+ mkdir -p /tmp/android-tv-apks
+ cp apps/android-tv/app/build/outputs/apk/debug/app-debug.apk "/tmp/android-tv-apks/moontvplus-tv-${{ inputs.version_name }}-debug.apk"
+ if [ -f apps/android-tv/app/build/outputs/apk/release/app-release.apk ]; then
+ cp apps/android-tv/app/build/outputs/apk/release/app-release.apk "/tmp/android-tv-apks/moontvplus-tv-${{ inputs.version_name }}-release-signed.apk"
+ fi
+ if [ -f apps/android-tv/app/build/outputs/apk/release/app-release-unsigned.apk ]; then
+ cp apps/android-tv/app/build/outputs/apk/release/app-release-unsigned.apk "/tmp/android-tv-apks/moontvplus-tv-${{ inputs.version_name }}-release-unsigned.apk"
+ fi
+ ls -lh /tmp/android-tv-apks
+
+ - name: Upload APK artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: moontvplus-android-tv-${{ inputs.version_name }}
+ path: /tmp/android-tv-apks/*.apk
+ if-no-files-found: error
+ retention-days: 30
diff --git a/apps/android-tv/README.md b/apps/android-tv/README.md
new file mode 100644
index 0000000..59fa029
--- /dev/null
+++ b/apps/android-tv/README.md
@@ -0,0 +1,25 @@
+# MoonTVPlus Android TV
+
+这是一个极简 Android TV WebView 壳工程,用于打开 MoonTVPlus Web TV 页面。
+
+## 构建参数
+
+- `BASE_URL`: 服务端 Base URL,不需要带 `/tv`,例如 `https://example.com` 或 `http://192.168.1.10:3000`
+- `APP_NAME`: Android TV 桌面显示名称
+- `VERSION_NAME`: APK 版本名
+- `VERSION_CODE`: APK 版本号,整数
+
+App 启动时会自动打开:
+
+```text
+BASE_URL 去掉末尾 / 后 + /tv
+```
+
+## 特性
+
+- 锁定横屏
+- 支持 Android TV Launcher
+- 允许 HTTP 明文访问
+- 允许 HTTPS 页面加载 HTTP 视频/图片等混合内容
+- 使用 `public/logo.png` 作为图标来源
+- User-Agent 追加 `MoonTVPlusAndroidTV`
diff --git a/apps/android-tv/app/build.gradle.kts b/apps/android-tv/app/build.gradle.kts
new file mode 100644
index 0000000..b3ba4d5
--- /dev/null
+++ b/apps/android-tv/app/build.gradle.kts
@@ -0,0 +1,70 @@
+plugins {
+ id("com.android.application")
+}
+
+fun propOrEnv(propName: String, envName: String, defaultValue: String): String {
+ return (project.findProperty(propName) as String?)
+ ?: System.getenv(envName)
+ ?: defaultValue
+}
+
+val rawBaseUrl = propOrEnv("BASE_URL", "BASE_URL", "http://192.168.1.10:3000")
+val appDisplayName = propOrEnv("APP_NAME", "APP_NAME", "MoonTVPlus TV")
+val versionNameValue = propOrEnv("VERSION_NAME", "VERSION_NAME", "1.0.0")
+val versionCodeValue = propOrEnv("VERSION_CODE", "VERSION_CODE", "1").toIntOrNull() ?: 1
+
+fun escapeJavaString(value: String): String = value
+ .replace("\\", "\\\\")
+ .replace("\"", "\\\"")
+
+fun escapeXmlString(value: String): String = value
+ .replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("'", "\\'")
+ .replace("\"", "\\\"")
+
+android {
+ namespace = "com.moontvplus.tv"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "com.moontvplus.tv"
+ minSdk = 23
+ targetSdk = 35
+ versionCode = versionCodeValue
+ versionName = versionNameValue
+
+ buildConfigField("String", "BASE_URL", "\"${escapeJavaString(rawBaseUrl)}\"")
+ resValue("string", "app_name", escapeXmlString(appDisplayName))
+ }
+
+ buildFeatures {
+ buildConfig = true
+ }
+
+ signingConfigs {
+ create("release") {
+ val storeFilePath = System.getenv("ANDROID_KEYSTORE_PATH")
+ if (!storeFilePath.isNullOrBlank() && file(storeFilePath).exists()) {
+ storeFile = file(storeFilePath)
+ storePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
+ keyAlias = System.getenv("ANDROID_KEY_ALIAS")
+ keyPassword = System.getenv("ANDROID_KEY_PASSWORD")
+ }
+ }
+ }
+
+ buildTypes {
+ debug {
+ isDebuggable = true
+ }
+ release {
+ isMinifyEnabled = false
+ val storeFilePath = System.getenv("ANDROID_KEYSTORE_PATH")
+ if (!storeFilePath.isNullOrBlank() && file(storeFilePath).exists()) {
+ signingConfig = signingConfigs.getByName("release")
+ }
+ }
+ }
+}
diff --git a/apps/android-tv/app/src/main/AndroidManifest.xml b/apps/android-tv/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..74e1431
--- /dev/null
+++ b/apps/android-tv/app/src/main/AndroidManifest.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/android-tv/app/src/main/java/com/moontvplus/tv/MainActivity.java b/apps/android-tv/app/src/main/java/com/moontvplus/tv/MainActivity.java
new file mode 100644
index 0000000..c198be6
--- /dev/null
+++ b/apps/android-tv/app/src/main/java/com/moontvplus/tv/MainActivity.java
@@ -0,0 +1,200 @@
+package com.moontvplus.tv;
+
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.graphics.Bitmap;
+import android.net.http.SslError;
+import android.os.Bundle;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager;
+import android.webkit.SslErrorHandler;
+import android.webkit.WebChromeClient;
+import android.webkit.WebResourceError;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.FrameLayout;
+
+public class MainActivity extends Activity {
+ private FrameLayout root;
+ private WebView webView;
+ private View customView;
+ private WebChromeClient.CustomViewCallback customViewCallback;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ requestWindowFeature(Window.FEATURE_NO_TITLE);
+ getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
+ getWindow().getDecorView().setSystemUiVisibility(
+ View.SYSTEM_UI_FLAG_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
+ | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
+ | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
+ | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
+ );
+
+ root = new FrameLayout(this);
+ setContentView(root);
+ setupWebView();
+ webView.loadUrl(buildTvUrl(BuildConfig.BASE_URL));
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private void setupWebView() {
+ webView = new WebView(this);
+ webView.setFocusable(true);
+ webView.setFocusableInTouchMode(true);
+ webView.requestFocus();
+ root.addView(webView, new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ ));
+
+ WebSettings settings = webView.getSettings();
+ settings.setJavaScriptEnabled(true);
+ settings.setDomStorageEnabled(true);
+ settings.setDatabaseEnabled(true);
+ settings.setMediaPlaybackRequiresUserGesture(false);
+ settings.setLoadWithOverviewMode(true);
+ settings.setUseWideViewPort(true);
+ settings.setAllowFileAccess(false);
+ settings.setAllowContentAccess(true);
+ settings.setSupportZoom(false);
+ settings.setBuiltInZoomControls(false);
+ settings.setDisplayZoomControls(false);
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
+ settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
+ }
+ settings.setUserAgentString(settings.getUserAgentString() + " MoonTVPlusAndroidTV");
+
+ webView.setWebViewClient(new WebViewClient() {
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
+ return false;
+ }
+
+ @Override
+ public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
+ super.onReceivedError(view, request, error);
+ }
+
+ @Override
+ public void onPageStarted(WebView view, String url, Bitmap favicon) {
+ super.onPageStarted(view, url, favicon);
+ }
+
+ @Override
+ public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
+ // Do not bypass TLS certificate errors. HTTP is allowed by network security config;
+ // invalid HTTPS certificates should still be blocked for safety.
+ handler.cancel();
+ }
+ });
+
+ webView.setWebChromeClient(new WebChromeClient() {
+ @Override
+ public void onShowCustomView(View view, CustomViewCallback callback) {
+ if (customView != null) {
+ callback.onCustomViewHidden();
+ return;
+ }
+ customView = view;
+ customViewCallback = callback;
+ webView.setVisibility(View.GONE);
+ root.addView(customView, new FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ ));
+ }
+
+ @Override
+ public void onHideCustomView() {
+ hideCustomView();
+ }
+ });
+ }
+
+ private static String buildTvUrl(String baseUrl) {
+ String url = baseUrl == null ? "" : baseUrl.trim();
+ if (url.isEmpty()) {
+ url = "http://192.168.1.10:3000";
+ }
+ if (!url.startsWith("http://") && !url.startsWith("https://")) {
+ url = "http://" + url;
+ }
+ while (url.endsWith("/")) {
+ url = url.substring(0, url.length() - 1);
+ }
+ if (url.endsWith("/tv")) {
+ return url;
+ }
+ return url + "/tv";
+ }
+
+ private void hideCustomView() {
+ if (customView == null) {
+ return;
+ }
+ root.removeView(customView);
+ customView = null;
+ webView.setVisibility(View.VISIBLE);
+ if (customViewCallback != null) {
+ customViewCallback.onCustomViewHidden();
+ customViewCallback = null;
+ }
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+ if (event.getKeyCode() == KeyEvent.KEYCODE_MENU && event.getAction() == KeyEvent.ACTION_UP) {
+ webView.reload();
+ return true;
+ }
+ return super.dispatchKeyEvent(event);
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (customView != null) {
+ hideCustomView();
+ return;
+ }
+ if (webView != null && webView.canGoBack()) {
+ webView.goBack();
+ return;
+ }
+ super.onBackPressed();
+ }
+
+ @Override
+ protected void onResume() {
+ super.onResume();
+ if (webView != null) {
+ webView.onResume();
+ }
+ }
+
+ @Override
+ protected void onPause() {
+ if (webView != null) {
+ webView.onPause();
+ }
+ super.onPause();
+ }
+
+ @Override
+ protected void onDestroy() {
+ if (webView != null) {
+ root.removeView(webView);
+ webView.destroy();
+ webView = null;
+ }
+ super.onDestroy();
+ }
+}
diff --git a/apps/android-tv/app/src/main/res/drawable/logo.png b/apps/android-tv/app/src/main/res/drawable/logo.png
new file mode 100644
index 0000000..19ab2cc
Binary files /dev/null and b/apps/android-tv/app/src/main/res/drawable/logo.png differ
diff --git a/apps/android-tv/app/src/main/res/values/colors.xml b/apps/android-tv/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..0aae89d
--- /dev/null
+++ b/apps/android-tv/app/src/main/res/values/colors.xml
@@ -0,0 +1,3 @@
+
+ #000000
+
diff --git a/apps/android-tv/app/src/main/res/values/styles.xml b/apps/android-tv/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..1ec5d55
--- /dev/null
+++ b/apps/android-tv/app/src/main/res/values/styles.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/apps/android-tv/app/src/main/res/xml/network_security_config.xml b/apps/android-tv/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 0000000..2439f15
--- /dev/null
+++ b/apps/android-tv/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/apps/android-tv/build.gradle.kts b/apps/android-tv/build.gradle.kts
new file mode 100644
index 0000000..a0dd15f
--- /dev/null
+++ b/apps/android-tv/build.gradle.kts
@@ -0,0 +1,3 @@
+plugins {
+ id("com.android.application") version "8.7.3" apply false
+}
diff --git a/apps/android-tv/gradle.properties b/apps/android-tv/gradle.properties
new file mode 100644
index 0000000..ed2d02a
--- /dev/null
+++ b/apps/android-tv/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=false
+android.nonTransitiveRClass=true
diff --git a/apps/android-tv/settings.gradle.kts b/apps/android-tv/settings.gradle.kts
new file mode 100644
index 0000000..66db2fd
--- /dev/null
+++ b/apps/android-tv/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "MoonTVPlusAndroidTV"
+include(":app")