From 14c5ca32ce3cce2025bed44c291d0c58a07c937c Mon Sep 17 00:00:00 2001
From: juneix <81808039+juneix@users.noreply.github.com>
Date: Sun, 19 Apr 2026 14:12:23 +0800
Subject: [PATCH] v1.1
---
.github/patch_android.py | 232 ++++++++++++++++++++++++++++++++++
.github/workflows/release.yml | 175 +++++++++++++++++++++++++
.gitignore | 5 +-
en/index.html | 38 +++++-
package.json | 16 +++
zh/index.html | 39 +++++-
6 files changed, 490 insertions(+), 15 deletions(-)
create mode 100644 .github/patch_android.py
create mode 100644 .github/workflows/release.yml
create mode 100644 package.json
diff --git a/.github/patch_android.py b/.github/patch_android.py
new file mode 100644
index 0000000..5788c64
--- /dev/null
+++ b/.github/patch_android.py
@@ -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(
+ "",
+ ' \n'
+ )
+
+# 注册 EmbyXDreamService(Android 系统屏保服务)
+# 修改规格:如需换 label 或 icon,修改下方 android:label / android:permission 即可
+service_block = """
+
+
+
+
+
+
+
+"""
+
+if "EmbyXDreamService" not in manifest:
+ manifest = manifest.replace("", f"{service_block}\n ")
+
+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.html(EmbyX 会自动读取已保存的 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"""
+
+"""
+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})")
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..ba823ed
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -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.json(webDir 指向 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.json(webDir 指向 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 }}
diff --git a/.gitignore b/.gitignore
index 496ee2c..dc9c4fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,4 @@
-.DS_Store
\ No newline at end of file
+.DS_Store
+node_modules/
+/android/
+__pycache__/
\ No newline at end of file
diff --git a/en/index.html b/en/index.html
index d2239ff..7a5a63e 100644
--- a/en/index.html
+++ b/en/index.html
@@ -1762,28 +1762,52 @@
lucide.createIcons();
}
- executeDelete() {
+ async executeDelete() {
const item = this.state.videos[this.state.currentIndex];
if (!item) return;
- fetch(`${this.config.server}/emby/Items/${item.Id}?api_key=${this.config.token}`, {
- method: 'DELETE'
- }).then(() => {
+ try {
+ // Note: Emby DELETE API requires admin account; regular users get 403
+ // 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.state.videos.splice(this.state.currentIndex, 1);
this.toggleModal('deleteConfirmModal', false);
+
if (this.state.viewMode === 'grid') {
this.renderGridView();
} else {
if (this.state.currentIndex >= this.state.videos.length) {
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();
+ if (this.state.videos.length > 0) {
+ this.loadVideo(this.state.currentIndex);
+ this.playVideo(this.state.currentIndex);
+ }
}
- }).catch(() => {
- this.showToast('❌ Delete failed');
+ } catch (e) {
+ this.showToast('❌ Delete failed: network error');
this.toggleModal('deleteConfirmModal', false);
- });
+ }
}
showInterfaceTemp() {
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..85ecfaa
--- /dev/null
+++ b/package.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/zh/index.html b/zh/index.html
index c6cff91..27f10da 100644
--- a/zh/index.html
+++ b/zh/index.html
@@ -1899,16 +1899,36 @@
lucide.createIcons();
}
- executeDelete() {
+ async executeDelete() {
const item = this.state.videos[this.state.currentIndex];
if (!item) return;
- fetch(`${this.config.server}/emby/Items/${item.Id}?api_key=${this.config.token}`, {
- method: 'DELETE'
- }).then(() => {
+ try {
+ // 注意:Emby 删除 API 需要管理员账号,普通用户会返回 403
+ // 必须检查 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.state.videos.splice(this.state.currentIndex, 1);
this.toggleModal('deleteConfirmModal', false);
+
if (this.state.viewMode === 'grid') {
this.renderGridView();
} else {
@@ -1916,12 +1936,17 @@
if (this.state.currentIndex >= this.state.videos.length) {
this.state.currentIndex = Math.max(0, this.state.videos.length - 1);
}
+ // 修复:renderSlides 只重建 DOM,必须再调用 loadVideo+playVideo 才能自动播放
this.renderSlides();
+ if (this.state.videos.length > 0) {
+ this.loadVideo(this.state.currentIndex);
+ this.playVideo(this.state.currentIndex);
+ }
}
- }).catch(() => {
- this.showToast('❌ 删除失败');
+ } catch (e) {
+ this.showToast('❌ 删除失败:网络错误');
this.toggleModal('deleteConfirmModal', false);
- });
+ }
}
showInterfaceTemp() {