This commit is contained in:
juneix
2026-04-19 14:12:23 +08:00
parent 5ae163db13
commit 14c5ca32ce
6 changed files with 490 additions and 15 deletions
+232
View File
@@ -0,0 +1,232 @@
import os
import re
import sys
import shutil
import argparse
# 根据命令行参数决定打包中文版还是英文版
# 用法: python patch_android.py --lang zh 或 --lang en
parser = argparse.ArgumentParser()
parser.add_argument("--lang", choices=["zh", "en"], default="zh", help="打包语言版本")
args = parser.parse_args()
LANG = args.lang
PKG_NAME = "juneix.embyx"
PKG_PATH = PKG_NAME.replace(".", "/") # juneix/embyx
APP_NAME = "EmbyX"
ICON_SRC = f"{LANG}/icon.png" # zh/icon.png 或 en/icon.png
# ── 0. 从 HTML 徽章提取版本号 ─────────────────────────────────────────────────
# HTML 中的版本徽章格式为 ">v1.1<",两个版本的徽章内容保持一致,统一读 zh/index.html
# 修改规格:如需从其他文件读取版本,修改下方 VERSION_SRC 路径即可
VERSION_SRC = "zh/index.html"
version_name = "1.0" # 默认回退值
version_code = 100 # 对应 v1.0
if os.path.exists(VERSION_SRC):
with open(VERSION_SRC, "r", encoding="utf-8") as f:
html = f.read()
# 匹配徽章文本,例如 ">v1.1<" 或 ">v2.0<"(非贪婪,只取第一个)
m = re.search(r">v(\d+)\.(\d+)(?:\.(\d+))?<", html)
if m:
major = int(m.group(1))
minor = int(m.group(2))
patch = int(m.group(3)) if m.group(3) else 0
version_name = f"{major}.{minor}" if patch == 0 else f"{major}.{minor}.{patch}"
# versionCode 规则:major×10000 + minor×100 + patch
# 示例:v1.0→10000, v1.1→10100, v1.9→10900, v2.0→20000, v1.1.2→10102
# 这样三段版本号也能正确递增,且不会与两段版本号冲突
version_code = major * 10000 + minor * 100 + patch
print(f" Detected version: v{version_name} → versionCode={version_code}")
else:
print(f" WARNING: Version badge not found in {VERSION_SRC}, using default {version_name}")
else:
print(f" WARNING: {VERSION_SRC} not found, using default version {version_name}")
print(f"Patching Android Project for lang={LANG}, pkg={PKG_NAME}, version={version_name}...")
# ── 1. 图标文件 ──────────────────────────────────────────────────────────────
# 将对应语言版本的 icon.png 复制到 Android drawable 资源目录
os.makedirs("android/app/src/main/res/drawable", exist_ok=True)
if os.path.exists(ICON_SRC):
shutil.copy(ICON_SRC, "android/app/src/main/res/drawable/icon.png")
print(f" Copied {ICON_SRC} → drawable/icon.png")
else:
print(f" WARNING: {ICON_SRC} not found, skipping icon copy")
# ── 2. AndroidManifest.xml 补丁 ──────────────────────────────────────────────
manifest_path = "android/app/src/main/AndroidManifest.xml"
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = f.read()
# 替换默认图标引用为我们的 drawable/icon
manifest = manifest.replace("@mipmap/ic_launcher_round", "@drawable/icon")
manifest = manifest.replace("@mipmap/ic_launcher", "@drawable/icon")
manifest = manifest.replace("@drawable/icon_round", "@drawable/icon")
# 添加 WAKE_LOCK 权限(屏保/常亮需要)
if "android.permission.WAKE_LOCK" not in manifest:
manifest = manifest.replace(
"</manifest>",
' <uses-permission android:name="android.permission.WAKE_LOCK" />\n</manifest>'
)
# 注册 EmbyXDreamServiceAndroid 系统屏保服务)
# 修改规格:如需换 label 或 icon,修改下方 android:label / android:permission 即可
service_block = """
<service
android:name=".EmbyXDreamService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_DREAM_SERVICE">
<intent-filter>
<action android:name="android.service.dreams.DreamService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.service.dream"
android:resource="@xml/dream_info" />
</service>
"""
if "EmbyXDreamService" not in manifest:
manifest = manifest.replace("</application>", f"{service_block}\n </application>")
with open(manifest_path, "w", encoding="utf-8") as f:
f.write(manifest)
print(" Patched AndroidManifest.xml")
# ── 3. MainActivity.java 补丁 ────────────────────────────────────────────────
# 添加全屏沉浸式模式 + FLAG_KEEP_SCREEN_ON(视频播放常亮)
# 修改规格:如需改变沉浸式行为,修改下方 SYSTEM_UI_FLAG_* 标志位组合
main_activity_path = f"android/app/src/main/java/{PKG_PATH}/MainActivity.java"
with open(main_activity_path, "r", encoding="utf-8") as f:
main_activity = f.read()
immersive_code = """
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) {
getWindow().getDecorView().setSystemUiVisibility(
android.view.View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| android.view.View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| android.view.View.SYSTEM_UI_FLAG_FULLSCREEN);
}
}
"""
# 添加 FLAG_KEEP_SCREEN_ON(视频播放常亮,防止熄屏打断视频)
if "FLAG_KEEP_SCREEN_ON" not in main_activity:
main_activity = main_activity.replace(
"super.onCreate(savedInstanceState);",
"super.onCreate(savedInstanceState);\n getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);"
)
# 添加全屏沉浸式覆盖
if "onWindowFocusChanged" not in main_activity:
main_activity = main_activity.replace("}", immersive_code + "\n}", 1)
with open(main_activity_path, "w", encoding="utf-8") as f:
f.write(main_activity)
print(" Patched MainActivity.java (fullscreen + keep screen on)")
# ── 4. EmbyXDreamService.java ───────────────────────────────────────────────
# 屏保实现:使用 Android DreamService,内嵌 WebView 加载 EmbyX 本地页面
# Capacitor 打包后的 webDir 内容位于 file:///android_asset/public/index.html
# EmbyX 会读取 localStorage 中已保存的 Emby 服务器配置,自动连接并播放
# 修改规格:如需改变屏保交互性,修改 setInteractive(true/false)
dream_service_code = f"""package {PKG_NAME};
import android.service.dreams.DreamService;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.view.WindowManager;
public class EmbyXDreamService extends DreamService {{
private WebView webView;
@Override
public void onAttachedToWindow() {{
super.onAttachedToWindow();
// 允许用户与屏保交互(点击/滑动视频)
setInteractive(true);
setFullscreen(true);
webView = new WebView(this);
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true); // localStorage 需要,用于读取 Emby token
settings.setDatabaseEnabled(true);
settings.setMediaPlaybackRequiresUserGesture(false); // 屏保自动播放视频无需手势
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); // 允许 HTTP 视频流
webView.setWebViewClient(new WebViewClient());
// 加载 Capacitor 打包的本地 index.htmlEmbyX 会自动读取已保存的 Emby 配置)
webView.loadUrl("file:///android_asset/public/index.html");
setContentView(webView);
}}
@Override
public void onDreamingStarted() {{
super.onDreamingStarted();
// 屏保激活时保持屏幕常亮(播放视频需要)
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}}
@Override
public void onDreamingStopped() {{
super.onDreamingStopped();
if (webView != null) {{
webView.loadUrl("about:blank");
webView.destroy();
webView = null;
}}
}}
}}
"""
dream_service_path = f"android/app/src/main/java/{PKG_PATH}/EmbyXDreamService.java"
with open(dream_service_path, "w", encoding="utf-8") as f:
f.write(dream_service_code)
print(f" Created EmbyXDreamService.java at {dream_service_path}")
# ── 5. dream_info.xml ────────────────────────────────────────────────────────
# Android 需要此 XML 文件来注册屏保,previewImage 显示在系统屏保选择列表中
os.makedirs("android/app/src/main/res/xml", exist_ok=True)
xml_code = f"""<?xml version="1.0" encoding="utf-8"?>
<dream android:settingsActivity="{PKG_NAME}.MainActivity"
android:previewImage="@drawable/icon"
xmlns:android="http://schemas.android.com/apk/res/android" />
"""
with open("android/app/src/main/res/xml/dream_info.xml", "w", encoding="utf-8") as f:
f.write(xml_code)
print(" Created dream_info.xml")
# ── 6. 注入 APK 版本号到 build.gradle ───────────────────────────────────────
# Android 覆盖安装依赖 versionCode(整数)必须递增,versionName 是人类可读标签
# Capacitor 生成的默认值是 versionCode=1, versionName="1.0"
# 这里用从 HTML 读取的版本替换,保证每次发布 APK 能覆盖旧版本
# 修改规格:versionCode 算法在脚本顶部第 0 步,修改 major/minor/patch 的乘数即可
gradle_path = "android/app/build.gradle"
if os.path.exists(gradle_path):
with open(gradle_path, "r", encoding="utf-8") as f:
gradle = f.read()
# 替换 versionCode(形如 "versionCode 1" 或 "versionCode 10000"
gradle = re.sub(r"versionCode\s+\d+", f"versionCode {version_code}", gradle)
# 替换 versionName(形如 versionName "1.0"
gradle = re.sub(r'versionName\s+"[^"]+"', f'versionName "{version_name}"', gradle)
with open(gradle_path, "w", encoding="utf-8") as f:
f.write(gradle)
print(f" Patched build.gradle → versionCode={version_code}, versionName=\"{version_name}\"")
else:
print(f" WARNING: {gradle_path} not found, skipping version injection")
print(f"\nPatch complete! ({LANG} version, pkg={PKG_NAME}, v{version_name})")
+175
View File
@@ -0,0 +1,175 @@
name: Android APK 构建
# 触发条件:
# 1. 每次推送代码到 main 分支:自动构建并更新名为 "latest" 的 Release (Rolling Release)
# 2. 推送 v* 格式的 tag(如 v1.0):自动构建并发布正式 Release
# 3. 手动触发(workflow_dispatch):手动指定 tag 发布
on:
push:
branches: [ main ]
tags: [ 'v*' ]
workflow_dispatch:
inputs:
release_tag:
description: '上传到已有标签 (如 v1.0)'
required: true
default: 'v1.0'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: write
jobs:
# ── 中文版 APK 构建 ──────────────────────────────────────────────────────────
build-android-zh:
name: Build EmbyX-zh APK
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
# 动态生成中文版 capacitor.config.jsonwebDir 指向 zh/ 目录)
- name: Create capacitor.config.json (zh)
run: |
cat > capacitor.config.json << 'EOF'
{
"appId": "juneix.embyx",
"appName": "EmbyX",
"webDir": "zh",
"server": {
"androidScheme": "http",
"cleartext": true
}
}
EOF
- name: Install Capacitor dependencies
run: npm install
- name: Add & Sync Android Platform
run: |
npx cap add android
npx cap sync android
# 运行补丁脚本:替换图标、注册屏保、添加全屏模式
- name: Patch Android Project (zh)
run: python .github/patch_android.py --lang zh
- name: Build Debug APK
run: |
cd android
chmod +x gradlew
./gradlew assembleDebug
- name: Rename to EmbyX-zh.apk
run: mv android/app/build/outputs/apk/debug/app-debug.apk EmbyX-zh.apk
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: embyx-zh
path: EmbyX-zh.apk
# ── 英文版 APK 构建 ──────────────────────────────────────────────────────────
build-android-en:
name: Build EmbyX-en APK
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
# 动态生成英文版 capacitor.config.jsonwebDir 指向 en/ 目录)
- name: Create capacitor.config.json (en)
run: |
cat > capacitor.config.json << 'EOF'
{
"appId": "juneix.embyx",
"appName": "EmbyX",
"webDir": "en",
"server": {
"androidScheme": "http",
"cleartext": true
}
}
EOF
- name: Install Capacitor dependencies
run: npm install
- name: Add & Sync Android Platform
run: |
npx cap add android
npx cap sync android
# 运行补丁脚本:替换图标、注册屏保、添加全屏模式
- name: Patch Android Project (en)
run: python .github/patch_android.py --lang en
- name: Build Debug APK
run: |
cd android
chmod +x gradlew
./gradlew assembleDebug
- name: Rename to EmbyX-en.apk
run: mv android/app/build/outputs/apk/debug/app-debug.apk EmbyX-en.apk
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: embyx-en
path: EmbyX-en.apk
# ── 发布 Release ─────────────────────────────────────────────────────────────
# 当推送到 main 分支、打 v* 标签或手动触发时执行
release:
name: Create GitHub Release
needs: [build-android-zh, build-android-en]
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') || github.ref == 'refs/heads/main'
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Verify APK files exist
run: |
ls -R
test -f "embyx-zh/EmbyX-zh.apk"
test -f "embyx-en/EmbyX-en.apk"
- name: Create Release
uses: softprops/action-gh-release@v2
with:
# 如果是 main 分支,固定使用 latest 标签以便滚动更新
tag_name: ${{ github.ref_name == 'main' && 'latest' || (github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name) }}
name: ${{ github.ref_name == 'main' && 'Latest Build (Rolling)' || (github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name) }}
prerelease: ${{ github.ref_name == 'main' }}
draft: false
fail_on_unmatched_files: true
files: |
embyx-zh/EmbyX-zh.apk
embyx-en/EmbyX-en.apk
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3
View File
@@ -1 +1,4 @@
.DS_Store .DS_Store
node_modules/
/android/
__pycache__/
+31 -7
View File
@@ -1762,28 +1762,52 @@
lucide.createIcons(); lucide.createIcons();
} }
executeDelete() { async executeDelete() {
const item = this.state.videos[this.state.currentIndex]; const item = this.state.videos[this.state.currentIndex];
if (!item) return; if (!item) return;
fetch(`${this.config.server}/emby/Items/${item.Id}?api_key=${this.config.token}`, { try {
method: 'DELETE' // Note: Emby DELETE API requires admin account; regular users get 403
}).then(() => { // Must check res.ok, otherwise 403/404 are silently treated as success
const res = await fetch(`${this.config.server}/emby/Items/${item.Id}?api_key=${this.config.token}`, {
method: 'DELETE'
});
if (!res.ok) {
if (res.status === 403) {
this.showToast('❌ No permission. Use an admin account');
this.toggleModal('deleteConfirmModal', false);
return;
}
if (res.status !== 404) {
this.showToast(`❌ Delete failed (${res.status})`);
this.toggleModal('deleteConfirmModal', false);
return;
}
// 404 means already deleted — still remove from list
}
this.showToast('✅ Deleted'); this.showToast('✅ Deleted');
this.state.videos.splice(this.state.currentIndex, 1); this.state.videos.splice(this.state.currentIndex, 1);
this.toggleModal('deleteConfirmModal', false); this.toggleModal('deleteConfirmModal', false);
if (this.state.viewMode === 'grid') { if (this.state.viewMode === 'grid') {
this.renderGridView(); this.renderGridView();
} else { } else {
if (this.state.currentIndex >= this.state.videos.length) { if (this.state.currentIndex >= this.state.videos.length) {
this.state.currentIndex = Math.max(0, this.state.videos.length - 1); this.state.currentIndex = Math.max(0, this.state.videos.length - 1);
} }
// Fix: renderSlides only rebuilds DOM; must call loadVideo+playVideo to resume playback
this.renderSlides(); this.renderSlides();
if (this.state.videos.length > 0) {
this.loadVideo(this.state.currentIndex);
this.playVideo(this.state.currentIndex);
}
} }
}).catch(() => { } catch (e) {
this.showToast('❌ Delete failed'); this.showToast('❌ Delete failed: network error');
this.toggleModal('deleteConfirmModal', false); this.toggleModal('deleteConfirmModal', false);
}); }
} }
showInterfaceTemp() { showInterfaceTemp() {
+16
View File
@@ -0,0 +1,16 @@
{
"name": "embyx",
"version": "1.0.0",
"description": "Emby 短视频播放器",
"main": "index.js",
"scripts": {
"build:android": "npx cap sync android"
},
"dependencies": {
"@capacitor/android": "^5.0.0",
"@capacitor/core": "^5.0.0"
},
"devDependencies": {
"@capacitor/cli": "^5.0.0"
}
}
+32 -7
View File
@@ -1899,16 +1899,36 @@
lucide.createIcons(); lucide.createIcons();
} }
executeDelete() { async executeDelete() {
const item = this.state.videos[this.state.currentIndex]; const item = this.state.videos[this.state.currentIndex];
if (!item) return; if (!item) return;
fetch(`${this.config.server}/emby/Items/${item.Id}?api_key=${this.config.token}`, { try {
method: 'DELETE' // 注意:Emby 删除 API 需要管理员账号,普通用户会返回 403
}).then(() => { // 必须检查 res.ok,否则 403/404 也会被当成成功处理,导致前端假删除
const res = await fetch(`${this.config.server}/emby/Items/${item.Id}?api_key=${this.config.token}`, {
method: 'DELETE'
});
if (!res.ok) {
// 403:账号无管理权限;404:视频已不存在(也从列表移除)
if (res.status === 403) {
this.showToast('❌ 权限不足,请使用管理员账号');
this.toggleModal('deleteConfirmModal', false);
return;
}
if (res.status !== 404) {
this.showToast(`❌ 删除失败 (${res.status})`);
this.toggleModal('deleteConfirmModal', false);
return;
}
// 404 视为已删除,继续从列表移除
}
this.showToast('✅ 已删除'); this.showToast('✅ 已删除');
this.state.videos.splice(this.state.currentIndex, 1); this.state.videos.splice(this.state.currentIndex, 1);
this.toggleModal('deleteConfirmModal', false); this.toggleModal('deleteConfirmModal', false);
if (this.state.viewMode === 'grid') { if (this.state.viewMode === 'grid') {
this.renderGridView(); this.renderGridView();
} else { } else {
@@ -1916,12 +1936,17 @@
if (this.state.currentIndex >= this.state.videos.length) { if (this.state.currentIndex >= this.state.videos.length) {
this.state.currentIndex = Math.max(0, this.state.videos.length - 1); this.state.currentIndex = Math.max(0, this.state.videos.length - 1);
} }
// 修复:renderSlides 只重建 DOM,必须再调用 loadVideo+playVideo 才能自动播放
this.renderSlides(); this.renderSlides();
if (this.state.videos.length > 0) {
this.loadVideo(this.state.currentIndex);
this.playVideo(this.state.currentIndex);
}
} }
}).catch(() => { } catch (e) {
this.showToast('❌ 删除失败'); this.showToast('❌ 删除失败:网络错误');
this.toggleModal('deleteConfirmModal', false); this.toggleModal('deleteConfirmModal', false);
}); }
} }
showInterfaceTemp() { showInterfaceTemp() {