将 LunaTV(xuhxjx/LunaTV)这个纯网页项目,打包成一个安卓 APP。这个 APP 必须能正确处理全屏播放(自动旋转、隐藏状态栏、适配刘海屏),并且在启动和退出全屏时能正确显示/隐藏状态栏。
阶段一:【准备】安装所有必备工具
这是“搭建工厂”的阶段,在您的 Windows 电脑上准备好所有开发工具。
1. Node.js: 确保已安装(您使用的是 v20.19.5)。
2. Git: 确保已安装(用于下载项目)。
3. Android Studio: 确保已安装,并已完成所有初始 SDK 组件的下载。
4. pnpm / npm: 确保 Node.js 自带的 npm 可用。
阶段二:【改造】项目配置与依赖(命令行)
这是最复杂、也是最关键的阶段。我们把“纯网页” (LunaTV) 改造为“可以打包的 APP”。
1. 获取项目代码:
2. 【关键修复-1】解决“依赖冲突”:
LunaTV 项目的 package.json 依赖是过时且损坏的。
pnpm install 和 npm install 都会失败(ERESOLVE 错误)。
最终方案: 我们必须使用 npm 并附带一个“强制”参数来解决。
打开cmd命令窗口操作:
1 2 3 4
| rd /s /q node_modules del pnpm-lock.yaml del package-lock.json
|
1 2
| npm install --legacy-peer-deps
|
- (这个过程会很慢(10-20分钟),并且会显示很多 WARN 警告和 vulnerabilities 漏洞,这些都是正常的,可以安全忽略。)
3. 安装 Capacitor(添加“APP底盘”):
1 2
| npm install @capacitor/core --legacy-peer-deps
|
1 2
| npm install @capacitor/android --legacy-peer-deps
|
1 2
| npm install -D @capacitor/cli --legacy-peer-deps
|
4. 初始化 Capacitor(创建配置文件):
它会提问:
App Name? -> XxskyTV (您输入的名字)
App ID? -> com.xxsky.lunatv (您输入的ID)
web asset directory? -> (我们最初填了 out,这是错误的,应该保持默认public)
5. 【关键修复-2】配置 Capacitor 并“欺骗”它:
npx cap add 命令需要一个包含 index.html 的 webDir(网页目录)才能运行,但 LunaTV 并没有现成的。
最终方案: 我们将 webDir 指向 public 文件夹,并手动在里面创建一个“假”的 index.html。
操作:
用记事本打开 D:\Tools\LunaTV\capacitor.config.ts 文件。
将其内容替换为(确保填入您的 appId, appName 和正确的 url):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| import { CapacitorConfig } from '@capacitor/cli'; // <-- 关键:添加这一行
const config: CapacitorConfig = { appId: 'com.xxsky.lunatv', // 您的 App ID appName: 'XxskyTV', // 您的 App Name
// 【关键】必须指向 'public' webDir: 'public',
// 【关键】指向您的服务器网址 server: { url: 'https://mv.hxjx.dpdns.org' } };
export default config;
|
- 保存并关闭文件。
- 回到 cmd 命令行,运行“欺骗”命令:
1 2
| echo "<html></html>" > public/index.html
|
6. 构建与添加平台:
- (在这个过程中,我们还安装了 @capacitor/status-bar 和 @capacitor/screen-orientation 插件,但最后发现它们是“敌人”,所以我们最终把它们卸载了。最终的完美方案里不需要它们。)
阶段三:【原生修复】解决全屏 BUG(Android Studio)
这是我们能“完美实现”的最关键一步。我们发现所有问题(状态栏、刘海屏、旋转)都必须通过修改原生 Java 代码来接管,而不是靠插件。
1. 打开项目:
2. 等待 Gradle 同步:
3.【终极修复】替换 MainActivity.java:
这是我们所有修复工作的结晶。这段代码教会了 APP 如何像“浏览器”一样处理全屏。
操作:
在 Android Studio 的左侧文件树中,导航到: app -> java -> com.xxsky.lunatv -> MainActivity.java
双击打开它。
删除里面的所有内容。
粘贴以下**(这个版本 = 刘海修复 + 全屏播放 + 自动旋转 + “智能”隐藏UI + 【新功能】“完整”的“返回键”拦截)**的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142
| package com.xxsky.lunatv;
import android.content.pm.ActivityInfo; import android.os.Build; import android.os.Bundle; import android.view.View; import android.view.ViewGroup; import android.view.Window; import android.view.WindowManager; import android.webkit.WebChromeClient; import android.webkit.WebView; import android.widget.FrameLayout; import com.getcapacitor.BridgeActivity; import android.view.WindowInsets; import android.view.WindowInsetsController;
public class MainActivity extends BridgeActivity {
private View customView; private WebChromeClient.CustomViewCallback customViewCallback; private FrameLayout fullscreenContainer; private WebView webView;
@Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { Window w = getWindow(); WindowManager.LayoutParams lp = w.getAttributes(); lp.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; w.setAttributes(lp); }
this.webView = this.bridge.getWebView();
this.webView.setWebChromeClient(new WebChromeClient() {
@Override public void onShowCustomView(View view, CustomViewCallback callback) { if (customView != null) { onHideCustomView(); return; } customView = view; customViewCallback = callback; fullscreenContainer = new FrameLayout(MainActivity.this); fullscreenContainer.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); fullscreenContainer.setBackgroundColor(getResources().getColor(android.R.color.black)); fullscreenContainer.addView(customView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); ViewGroup decorView = (ViewGroup) getWindow().getDecorView(); decorView.addView(fullscreenContainer, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
hideSystemUI(); setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); }
@Override public void onHideCustomView() { if (customView == null) { return; } ViewGroup decorView = (ViewGroup) getWindow().getDecorView(); decorView.removeView(fullscreenContainer); fullscreenContainer = null; customView = null; if (customViewCallback != null) { customViewCallback.onCustomViewHidden(); }
showSystemUI(); setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); } }); }
@Override public void onBackPressed() {
if (this.customView != null) {
ViewGroup decorView = (ViewGroup) getWindow().getDecorView(); decorView.removeView(fullscreenContainer); fullscreenContainer = null; customView = null; if (customViewCallback != null) { customViewCallback.onCustomViewHidden(); } showSystemUI(); setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
} else if (this.webView != null && this.webView.canGoBack()) { this.webView.goBack(); } else { super.onBackPressed(); } }
private void hideSystemUI() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 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 { View decorView = getWindow().getDecorView(); decorView.setSystemUiVisibility( View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN); } }
private void showSystemUI() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { WindowInsetsController controller = getWindow().getInsetsController(); if (controller != null) { controller.show(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars()); } } else { View decorView = getWindow().getDecorView(); decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } } }
|
另一个不完美方案(这个版本 = 刘海修复 + 全屏播放 + 自动旋转 + “智能”隐藏UI + “完整”的“返回键”拦截+沉浸式‘网页标题栏与手机状态栏重叠’)的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154
| package com.xxsky.lunatv;
import android.content.pm.ActivityInfo; import android.os.Build; import android.os.Bundle; import android.view.View; import android.view.ViewGroup; import android.view.Window; import android.view.WindowManager; import android.webkit.WebChromeClient; import android.webkit.WebView; import android.widget.FrameLayout; import com.getcapacitor.BridgeActivity; import android.view.Window; import android.view.WindowManager; import android.os.Build; import android.view.WindowInsets; import android.view.WindowInsetsController;
public class MainActivity extends BridgeActivity {
private View customView; private WebChromeClient.CustomViewCallback customViewCallback; private FrameLayout fullscreenContainer; private WebView webView;
@Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { Window w = getWindow(); w.setDecorFitsSystemWindows(false); w.setFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); } else { Window w = getWindow(); w.setFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS); }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { Window w = getWindow(); WindowManager.LayoutParams lp = w.getAttributes(); lp.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; w.setAttributes(lp); }
this.webView = this.bridge.getWebView();
this.webView.setWebChromeClient(new WebChromeClient() {
@Override public void onShowCustomView(View view, CustomViewCallback callback) { if (customView != null) { onHideCustomView(); return; } customView = view; customViewCallback = callback; fullscreenContainer = new FrameLayout(MainActivity.this); fullscreenContainer.setLayoutParams(new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); fullscreenContainer.setBackgroundColor(getResources().getColor(android.R.color.black)); fullscreenContainer.addView(customView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); ViewGroup decorView = (ViewGroup) getWindow().getDecorView(); decorView.addView(fullscreenContainer, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
hideSystemUI(); setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); }
@Override public void onHideCustomView() { if (customView == null) { return; } ViewGroup decorView = (ViewGroup) getWindow().getDecorView(); decorView.removeView(fullscreenContainer); fullscreenContainer = null; customView = null; if (customViewCallback != null) { customViewCallback.onCustomViewHidden(); }
showSystemUI(); setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); } }); }
@Override public void onBackPressed() {
if (this.customView != null) {
ViewGroup decorView = (ViewGroup) getWindow().getDecorView(); decorView.removeView(fullscreenContainer); fullscreenContainer = null; customView = null; if (customViewCallback != null) { customViewCallback.onCustomViewHidden(); } showSystemUI(); setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
} else if (this.webView != null && this.webView.canGoBack()) { this.webView.goBack(); } else { super.onBackPressed(); } }
private void hideSystemUI() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 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 { View decorView = getWindow().getDecorView(); decorView.setSystemUiVisibility( View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN); } }
private void showSystemUI() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { WindowInsetsController controller = getWindow().getInsetsController(); if (controller != null) { controller.show(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars()); } } else { View decorView = getWindow().getDecorView(); decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE); } } }
|
阶段四:更改 APP 图标
1:准备您的图标文件
您需要一张正方形的图片。
格式: 最好是 .png 格式(支持透明背景)。
尺寸: 尺寸越大越好,Android Studio 会自动帮您缩放。理想尺寸是 1024x1024 像素(512x512 也可以)。
注意: 现代安卓图标有“安全区域”(通常是中间 66% 的圆形或圆角矩形)。请确保您的“核心”Logo 在这个安全区域内,否则它可能会被裁切。
准备好这张 .png 文件,并把它放在您电脑上一个容易找到的位置(比如“桌面”)。
2:使用 Android Studio 的“Asset Studio”工具
请回到您的 Android Studio(确保 D:\Tools\LunaTV\android 项目是打开的)。
在左上角的**“项目”文件浏览器**中(您之前找 MainActivity.java 的地方)。
右键点击最顶层的那个 app 文件夹(它有一个蓝色的“a”图标)。
在弹出的菜单中,移动到 “New” (新建) -> “Image Asset” (图像资产)。
3. 配置您的新图标
点击后,会弹出一个叫 “Asset Studio” 的新窗口。
Icon Type (图标类型): 保持默认的 Launcher Icons (Adaptive & Legacy)。
Name (名称): 保持默认的 ic_launcher。
Source Asset (源资产):
在 Path (路径) 这一行,点击它最右边的**“文件夹”图标**。
在弹出的文件选择器中,找到并选择您在步骤 1 中准备好的那张 .png 图标文件。
点击 OK。
调整 (Scaling):
您会立刻在右侧的“预览”窗口看到您的图标。
您可能会看到一个“安全区”的圆圈。
在中间的 Resize (调整大小) 滑块上,您可以左右拖动,把您的图标缩放到您觉得合适的大小(通常是让它刚好填满那个“安全区”)。
(可选)Background Layer (背景层):
如果您的 .png 是透明的,您可以在这里给它加一个纯色背景。
在 Name: ic_launcher 的下方,您会看到三个标签页 (Tab):
点击一下第二个标签页,也就是 Background Layer (背景层)
- Source Asset -> Asset Type -> Color (颜色),
- 然后选择您想要的背景色。
4. 生成图标
Android Studio 现在会自动生成所有不同尺寸(mdpi, hdpi, xhdpi 等)的图标,并替换掉项目里默认的那个 Capacitor 图标。
阶段五:启动logo图标
1. 准备您的新图片
2. 添加logo.png
请您再次找到并打开那个文件: app -> res -> drawable -> splash_layout.xml
回到 Android Studio。
在左侧文件树中,找到 res/drawable 文件夹。
右键点击 drawable 文件夹->open in->Explorer” (在资源管理器中显示)。
Windows 文件夹会打开 drawable 文件夹。
把logo.png 复制到这个文件夹里。 (我们不再需要 drawable-hdpi那些文件夹了)
###3. 创建“智能”布局文件 (splash_layout.xml)
回到 Android Studio。
右键点击 res/drawable 文件夹。
选择 “New” (新建) -> “Drawable Resource File” (Drawable 资源文件)。
File name (文件名): 请输入 splash_layout
Root element (根元素): 请输入 layer-list
点击 OK。
一个新的 splash_layout.xml 文件会生成。
在资源管理器中记事本打开,然后替换粘贴以下全部代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <?xml version="1.0" encoding="utf-8"?> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item> <color android:color="?android:attr/colorBackground" /> </item>
<item android:width="200dp" android:height="200dp" android:drawable="@drawable/logo" android:gravity="center" />
</layer-list>
|
*(重要!) 在粘贴后,请务必修改 android:drawable=”@drawable/logo” 这一行,把 logo 改成您自己的文件名(不带 .png)。
4. 告诉 APP 使用“新”的启动页
1 2 3 4 5
| <style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar"> <item name="android:windowBackground">@drawable/splash_layout</item> <item name="windowActionBar">false</item> <item name="windowNoTitle">true</item> </style>
|
或者
1 2 3 4 5
| <style name="AppTheme.NoActionBarLaunch" parent="Theme.AppCompat.Light.NoActionBar"> <item name="android:windowBackground">@drawable/splash_layout</item> <item name="windowActionBar">false</item> <item name="windowNoTitle">true</item> </style>
|
阶段六:【打包】生成最终的 APK
在 Android Studio 中,点击顶部菜单栏的 “Build” (构建)。
选择 “Generate App Bundles or APKs” (生成…)。
在子菜单中,选择 “Generate APKs” (生成 APKs)。
等待构建完成(BUILD SUCCESSFUL)。
在右下角弹出的通知中,点击 “Locate” (定位)。
最终成品: 您会得到 app-debug.apk 文件,它现在是“布局完美”且“功能完美”的。
附加七:更新操作
1. 如修改app名、App ID、server.url
- 路径:D:\Tools\MyNavApp\capacitor.config.ts
- 用记事本打开您项目根目录的capacitor.config.ts文件
1 2 3 4 5 6 7 8
| { "appId": "com.myname.nav", "appName": "我的新导航名字", <-- 修改这里 "webDir": "public", "server": { "url": "https://..." } }
|
2. 同步”到安卓
3. 重新打包:
打开 Android Studio(D:\Tools\MyNavApp\android)。
Android Studio -> file -> Sync project… -> 请点击它。
“Build” -> “Generate … APKs” -> “Generate APKs”。