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() {