Compare commits
18 Commits
e7813aeab3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 166b2c0eb9 | |||
| 490d7d4d5a | |||
| 96c65cbea6 | |||
| 174ba237be | |||
| a1722bc39b | |||
| 29021df1dc | |||
| 6548e193dc | |||
| 49cf1e90f5 | |||
| 7e8ac5845c | |||
| ddd91624f3 | |||
| 25585e7188 | |||
| 312acb3bdd | |||
| 60ce127b1e | |||
| edb8fff9fc | |||
| 28b4529ab8 | |||
| a4620862c9 | |||
| dcf600df3f | |||
| 36b92b2b64 |
+126
-39
@@ -4,38 +4,33 @@ 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
|
||||
PKG_PATH = PKG_NAME.replace(".", "/")
|
||||
APP_NAME = "EmbyX"
|
||||
ICON_SRC = f"{LANG}/icon.png" # zh/icon.png 或 en/icon.png
|
||||
ICON_SRC = f"{LANG}/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
|
||||
version_name = "1.0"
|
||||
version_code = 100
|
||||
|
||||
if os.path.exists(VERSION_SRC):
|
||||
with open(VERSION_SRC, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
# 匹配徽章文本,例如 ">v1.1<" 或 ">v2.0<"(非贪婪,只取第一个)
|
||||
# 匹配徽章文本,例如 ">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
|
||||
# 这样三段版本号也能正确递增,且不会与两段版本号冲突
|
||||
# versionCode 规则:major×10000 + minor×100 + patch 保证覆盖安装递增
|
||||
version_code = major * 10000 + minor * 100 + patch
|
||||
print(f" Detected version: v{version_name} → versionCode={version_code}")
|
||||
else:
|
||||
@@ -46,7 +41,6 @@ else:
|
||||
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")
|
||||
@@ -59,20 +53,19 @@ 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 权限(屏保/常亮需要)
|
||||
# 添加 WAKE_LOCK 权限(屏保与视频常亮需要)
|
||||
if "android.permission.WAKE_LOCK" not in manifest:
|
||||
manifest = manifest.replace(
|
||||
"</manifest>",
|
||||
' <uses-permission android:name="android.permission.WAKE_LOCK" />\n</manifest>'
|
||||
)
|
||||
|
||||
# 注册 EmbyXDreamService(Android 系统屏保服务)
|
||||
# 修改规格:如需换 label 或 icon,修改下方 android:label / android:permission 即可
|
||||
# 注册系统屏保服务 EmbyXDreamService
|
||||
service_block = """
|
||||
<service
|
||||
android:name=".EmbyXDreamService"
|
||||
@@ -97,8 +90,6 @@ with open(manifest_path, "w", encoding="utf-8") as f:
|
||||
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()
|
||||
@@ -119,7 +110,7 @@ immersive_code = """
|
||||
}
|
||||
"""
|
||||
|
||||
# 添加 FLAG_KEEP_SCREEN_ON(视频播放常亮,防止熄屏打断视频)
|
||||
# 添加 FLAG_KEEP_SCREEN_ON(防止视频播放时熄屏)
|
||||
if "FLAG_KEEP_SCREEN_ON" not in main_activity:
|
||||
main_activity = main_activity.replace(
|
||||
"super.onCreate(savedInstanceState);",
|
||||
@@ -135,10 +126,7 @@ with open(main_activity_path, "w", encoding="utf-8") as f:
|
||||
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)
|
||||
# 屏保服务使用 Android DreamService,内嵌 WebView 加载本地 index.html,读取 localStorage 中 Emby 配置自动播放
|
||||
dream_service_code = f"""package {PKG_NAME};
|
||||
|
||||
import android.service.dreams.DreamService;
|
||||
@@ -154,20 +142,18 @@ public class EmbyXDreamService extends DreamService {{
|
||||
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.setDomStorageEnabled(true);
|
||||
settings.setDatabaseEnabled(true);
|
||||
settings.setMediaPlaybackRequiresUserGesture(false); // 屏保自动播放视频无需手势
|
||||
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); // 允许 HTTP 视频流
|
||||
settings.setMediaPlaybackRequiresUserGesture(false);
|
||||
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
|
||||
|
||||
webView.setWebViewClient(new WebViewClient());
|
||||
// 加载 Capacitor 打包的本地 index.html(EmbyX 会自动读取已保存的 Emby 配置)
|
||||
webView.loadUrl("file:///android_asset/public/index.html");
|
||||
|
||||
setContentView(webView);
|
||||
@@ -176,7 +162,6 @@ public class EmbyXDreamService extends DreamService {{
|
||||
@Override
|
||||
public void onDreamingStarted() {{
|
||||
super.onDreamingStarted();
|
||||
// 屏保激活时保持屏幕常亮(播放视频需要)
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
}}
|
||||
|
||||
@@ -197,7 +182,6 @@ with open(dream_service_path, "w", encoding="utf-8") as f:
|
||||
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"
|
||||
@@ -209,19 +193,14 @@ with open("android/app/src/main/res/xml/dream_info.xml", "w", encoding="utf-8")
|
||||
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)
|
||||
# 替换 versionCode / versionName
|
||||
gradle = re.sub(r"versionCode(?:\s*=\s*|\s+)\d+", f"versionCode = {version_code}", gradle)
|
||||
gradle = re.sub(r'versionName(?:\s*=\s*|\s+)"[^"]+"', f'versionName = "{version_name}"', gradle)
|
||||
|
||||
with open(gradle_path, "w", encoding="utf-8") as f:
|
||||
f.write(gradle)
|
||||
@@ -229,4 +208,112 @@ if os.path.exists(gradle_path):
|
||||
else:
|
||||
print(f" WARNING: {gradle_path} not found, skipping version injection")
|
||||
|
||||
# ── 7. 启动页 (Splash Screen) 规范适配 ───────────────────────────────────
|
||||
res_dir = "android/app/src/main/res"
|
||||
os.makedirs(f"{res_dir}/values", exist_ok=True)
|
||||
os.makedirs(f"{res_dir}/drawable", exist_ok=True)
|
||||
|
||||
# 写入颜色资源
|
||||
colors_path = f"{res_dir}/values/colors.xml"
|
||||
colors_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="black">#000000</color>
|
||||
<color name="white">#FFFFFF</color>
|
||||
</resources>
|
||||
"""
|
||||
with open(colors_path, "w", encoding="utf-8") as f:
|
||||
f.write(colors_xml)
|
||||
|
||||
# 创建缩小的启动图辅助资源 (使用 inset 解决图标被裁切问题)
|
||||
# 核心逻辑:将原本占满 100% 的图标缩小到 60% 左右,使其落入 Google 的圆形安全区
|
||||
splash_icon_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<inset xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:drawable="@drawable/icon"
|
||||
android:insetLeft="20%"
|
||||
android:insetRight="20%"
|
||||
android:insetTop="20%"
|
||||
android:insetBottom="20%" />
|
||||
"""
|
||||
with open(f"{res_dir}/drawable/splash_icon_padded.xml", "w", encoding="utf-8") as f:
|
||||
f.write(splash_icon_xml)
|
||||
print(" Created splash_icon_padded.xml")
|
||||
|
||||
# ── 8. 修改主题 (Themes/Styles) 适配 Google SplashScreen API ─────────────────
|
||||
target_files = [
|
||||
"values/themes.xml",
|
||||
"values-night/themes.xml",
|
||||
"values/styles.xml",
|
||||
"values-v31/themes.xml"
|
||||
]
|
||||
|
||||
splash_style_found = False
|
||||
for rel_path in target_files:
|
||||
full_path = os.path.join(res_dir, rel_path)
|
||||
if os.path.exists(full_path):
|
||||
with open(full_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# A. 修复“第二步白屏”:将基础主题的窗体背景强行设为黑色
|
||||
# 针对 AppTheme 和 AppTheme.NoActionBar 注入 windowBackground,确保交接瞬间不闪白
|
||||
content = re.sub(
|
||||
r'(<style\s+name="AppTheme(?:\.NoActionBar)?"[^>]*>)(.*?)(</style>)',
|
||||
r'\1\2 <item name="android:windowBackground">@color/black</item>\n \3',
|
||||
content,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
|
||||
# B. 适配官方启动页:精准匹配并提取 AppTheme.NoActionBarLaunch 标签块
|
||||
style_block_pattern = r'(<style\s+name="AppTheme\.NoActionBarLaunch"[^>]*>)(.*?)(</style>)'
|
||||
m = re.search(style_block_pattern, content, flags=re.DOTALL)
|
||||
if m:
|
||||
start_tag = m.group(1)
|
||||
inner_items = m.group(2)
|
||||
|
||||
# 替换 parent 为官方支持的 Theme.SplashScreen
|
||||
start_tag = re.sub(r'parent="[^"]*"', 'parent="Theme.SplashScreen"', start_tag)
|
||||
|
||||
# 清理历史可能存在的冲突属性
|
||||
inner_items = re.sub(r'<item\s+name="android:background">.*?</item>', '', inner_items)
|
||||
inner_items = re.sub(r'<item\s+name="windowSplashScreenBackground">.*?</item>', '', inner_items)
|
||||
inner_items = re.sub(r'<item\s+name="windowSplashScreenAnimatedIcon">.*?</item>', '', inner_items)
|
||||
inner_items = re.sub(r'<item\s+name="postSplashScreenTheme">.*?</item>', '', inner_items)
|
||||
|
||||
splash_items = """
|
||||
<item name="windowSplashScreenBackground">@color/black</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon_padded</item>
|
||||
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
|
||||
"""
|
||||
# 重构整个 style 块并原位替换回文档
|
||||
new_block = f"{start_tag}{inner_items}{splash_items} </style>"
|
||||
content = content[:m.start()] + new_block + content[m.end():]
|
||||
|
||||
with open(full_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
print(f" Successfully patched {rel_path} to Standard SplashScreen API")
|
||||
splash_style_found = True
|
||||
|
||||
if not splash_style_found:
|
||||
print(" WARNING: AppTheme.NoActionBarLaunch style not found in any res files!")
|
||||
|
||||
# ── 9. MainActivity 注入启动页入口 ──────────────────────────────────────────
|
||||
with open(main_activity_path, "r", encoding="utf-8") as f:
|
||||
ma = f.read()
|
||||
|
||||
if "import androidx.core.splashscreen.SplashScreen;" not in ma:
|
||||
ma = ma.replace("import android.os.Bundle;", "import android.os.Bundle;\nimport androidx.core.splashscreen.SplashScreen;")
|
||||
|
||||
if "SplashScreen.installSplashScreen(this)" not in ma:
|
||||
# 注入官方 SplashScreen 入口,并增加“三重保险”防止闪白:
|
||||
# 1. installSplashScreen() 必须在 super.onCreate 之前
|
||||
# 2. 强行将 Window 背景设为黑(防止 Theme 没生效)
|
||||
# 3. 强行将 WebView 背景设为黑(防止 HTML 渲染慢)
|
||||
ma = ma.replace(
|
||||
"super.onCreate(savedInstanceState);",
|
||||
"SplashScreen.installSplashScreen(this);\n super.onCreate(savedInstanceState);\n // 三重保险:Window + WebView 全程变黑,彻底解决闪白问题\n getWindow().setBackgroundDrawable(new android.graphics.drawable.ColorDrawable(android.graphics.Color.BLACK));\n this.bridge.getWebView().setBackgroundColor(android.graphics.Color.BLACK);"
|
||||
)
|
||||
|
||||
with open(main_activity_path, "w", encoding="utf-8") as f:
|
||||
f.write(ma)
|
||||
print(" Injected installSplashScreen() and black background to MainActivity.java")
|
||||
|
||||
print(f"\nPatch complete! ({LANG} version, pkg={PKG_NAME}, v{version_name})")
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- 'README_zh.md'
|
||||
workflow_dispatch:
|
||||
|
||||
# 添加权限声明,确保可以推送到 GHCR
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: Lowercase the repo name
|
||||
run: echo "REPO_LOWER=${GITHUB_REPOSITORY_OWNER,,}" >> ${GITHUB_ENV}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/${{ env.REPO_LOWER }}/embyx:latest
|
||||
${{ secrets.DOCKER_USER }}/embyx:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+133
-116
@@ -1,9 +1,9 @@
|
||||
name: Android APK 构建
|
||||
|
||||
# 触发条件:
|
||||
# 1. 每次推送代码到 main 分支:自动构建并更新名为 "latest" 的 Release (Rolling Release)
|
||||
# 2. 推送 v* 格式的 tag(如 v1.0):自动构建并发布正式 Release
|
||||
# 3. 手动触发(workflow_dispatch):手动指定 tag 发布
|
||||
# 1. 推送到 main 分支:自动构建并更新 preview 滚动版本
|
||||
# 2. 推送 v* 格式 tag:构建正式版本
|
||||
# 3. 手动触发:指定 tag 发布
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
@@ -11,165 +11,182 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: '上传到已有标签 (如 v1.0)'
|
||||
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
|
||||
build-and-release:
|
||||
name: Build & Release EmbyX APKs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# 手动 clone,避免从 github.com 下载 actions/checkout 超时
|
||||
- name: Checkout
|
||||
run: |
|
||||
git init
|
||||
git remote add origin \
|
||||
http://oauth2:${{ secrets.GITHUB_TOKEN }}@192.168.1.66:3000/adminym/embyx.git
|
||||
git fetch --depth=1 origin ${{ github.sha }}
|
||||
git checkout FETCH_HEAD
|
||||
|
||||
- name: Setup JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
- name: Setup JDK 21 & Python3
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y wget gnupg python3
|
||||
wget -qO- https://packages.adoptium.net/artifactory/api/gpg/key/public \
|
||||
| gpg --dearmor > /usr/share/keyrings/adoptium.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] \
|
||||
https://packages.adoptium.net/artifactory/deb bullseye main" \
|
||||
> /etc/apt/sources.list.d/adoptium.list
|
||||
apt-get update -qq
|
||||
apt-get install -y temurin-21-jdk
|
||||
java -version
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
- name: Setup Node.js 22
|
||||
run: |
|
||||
npm install -g n
|
||||
N_NODE_MIRROR=https://npmmirror.com/mirrors/node n 22
|
||||
hash -r
|
||||
node --version
|
||||
|
||||
# 动态生成中文版 capacitor.config.json(webDir 指向 zh/ 目录)
|
||||
- name: Create capacitor.config.json (zh)
|
||||
# ── 配置 Gradle / Maven 国内镜像(避免访问 gradle.org / google.com)────
|
||||
- name: Configure Gradle & Maven Mirrors
|
||||
run: |
|
||||
mkdir -p ~/.gradle/init.d
|
||||
cat > ~/.gradle/init.d/mirrors.gradle << 'EOF'
|
||||
allprojects {
|
||||
buildscript {
|
||||
repositories {
|
||||
maven { url "https://maven.aliyun.com/repository/google" }
|
||||
maven { url "https://maven.aliyun.com/repository/central" }
|
||||
maven { url "https://maven.aliyun.com/repository/gradle-plugins" }
|
||||
}
|
||||
}
|
||||
repositories {
|
||||
maven { url "https://maven.aliyun.com/repository/google" }
|
||||
maven { url "https://maven.aliyun.com/repository/central" }
|
||||
maven { url "https://maven.aliyun.com/repository/gradle-plugins" }
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# ── 构建中文版 APK ──────────────────────────────────────────────────────
|
||||
- name: Build zh APK
|
||||
run: |
|
||||
cat > capacitor.config.json << 'EOF'
|
||||
{
|
||||
"appId": "juneix.embyx",
|
||||
"appName": "EmbyX",
|
||||
"webDir": "zh",
|
||||
"backgroundColor": "#000000",
|
||||
"server": {
|
||||
"androidScheme": "http",
|
||||
"cleartext": true
|
||||
},
|
||||
"plugins": {
|
||||
"SplashScreen": {
|
||||
"launchAutoHide": true,
|
||||
"backgroundColor": "#000000",
|
||||
"androidScaleType": "CENTER",
|
||||
"showSpinner": false
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Install Capacitor dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Add & Sync Android Platform
|
||||
run: |
|
||||
npm install
|
||||
npx cap add android
|
||||
sed -i 's|services.gradle.org/distributions|mirrors.huaweicloud.com/gradle|g' \
|
||||
android/gradle/wrapper/gradle-wrapper.properties
|
||||
npx cap sync android
|
||||
|
||||
# 运行补丁脚本:替换图标、注册屏保、添加全屏模式
|
||||
- name: Patch Android Project (zh)
|
||||
run: python .github/patch_android.py --lang zh
|
||||
|
||||
- name: Build Debug APK
|
||||
run: |
|
||||
python3 .github/patch_android.py --lang zh
|
||||
cd android
|
||||
chmod +x gradlew
|
||||
./gradlew assembleDebug
|
||||
mv app/build/outputs/apk/debug/app-debug.apk ../EmbyX-zh.apk
|
||||
cd ..
|
||||
|
||||
- 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)
|
||||
# ── 构建英文版 APK(复用已下载的 Gradle 缓存)──────────────────────────
|
||||
- name: Build en APK
|
||||
run: |
|
||||
rm -rf android
|
||||
cat > capacitor.config.json << 'EOF'
|
||||
{
|
||||
"appId": "juneix.embyx",
|
||||
"appName": "EmbyX",
|
||||
"webDir": "en",
|
||||
"backgroundColor": "#000000",
|
||||
"server": {
|
||||
"androidScheme": "http",
|
||||
"cleartext": true
|
||||
},
|
||||
"plugins": {
|
||||
"SplashScreen": {
|
||||
"launchAutoHide": true,
|
||||
"backgroundColor": "#000000",
|
||||
"androidScaleType": "CENTER",
|
||||
"showSpinner": false
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Install Capacitor dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Add & Sync Android Platform
|
||||
run: |
|
||||
npm install
|
||||
npx cap add android
|
||||
sed -i 's|services.gradle.org/distributions|mirrors.huaweicloud.com/gradle|g' \
|
||||
android/gradle/wrapper/gradle-wrapper.properties
|
||||
npx cap sync android
|
||||
|
||||
# 运行补丁脚本:替换图标、注册屏保、添加全屏模式
|
||||
- name: Patch Android Project (en)
|
||||
run: python .github/patch_android.py --lang en
|
||||
|
||||
- name: Build Debug APK
|
||||
run: |
|
||||
python3 .github/patch_android.py --lang en
|
||||
cd android
|
||||
chmod +x gradlew
|
||||
./gradlew assembleDebug
|
||||
mv app/build/outputs/apk/debug/app-debug.apk ../EmbyX-en.apk
|
||||
cd ..
|
||||
|
||||
- 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"
|
||||
|
||||
# ── 发布到 Gitea Release(全程用本地 API,不访问 GitHub)───────────────
|
||||
- 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 }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
TAG="${{ inputs.release_tag }}"
|
||||
RNAME="${{ inputs.release_tag }}"
|
||||
PRE=false
|
||||
elif [ "${{ github.ref_name }}" = "main" ]; then
|
||||
TAG="preview"
|
||||
RNAME="EmbyX Preview"
|
||||
PRE=true
|
||||
else
|
||||
TAG="${{ github.ref_name }}"
|
||||
RNAME="${{ github.ref_name }}"
|
||||
PRE=false
|
||||
fi
|
||||
|
||||
API="http://192.168.1.66:3000/api/v1/repos/adminym/embyx"
|
||||
AUTH="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
# 删除旧的同名 release(用于 preview 滚动更新)
|
||||
OLD_ID=$(curl -sf "$API/releases/tags/$TAG" -H "$AUTH" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" \
|
||||
2>/dev/null || echo "")
|
||||
if [ -n "$OLD_ID" ] && [ "$OLD_ID" != "None" ]; then
|
||||
curl -sf -X DELETE "$API/releases/$OLD_ID" -H "$AUTH" || true
|
||||
curl -sf -X DELETE "$API/tags/$TAG" -H "$AUTH" || true
|
||||
fi
|
||||
|
||||
# 创建新 release
|
||||
RID=$(curl -sf -X POST "$API/releases" \
|
||||
-H "$AUTH" -H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"$RNAME\",\"prerelease\":$PRE,\"draft\":false}" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
|
||||
echo "Release ID: $RID"
|
||||
|
||||
# 上传中文版 APK
|
||||
curl -sf -X POST "$API/releases/$RID/assets?name=EmbyX-zh.apk" \
|
||||
-H "$AUTH" -F "attachment=@EmbyX-zh.apk"
|
||||
|
||||
# 上传英文版 APK
|
||||
curl -sf -X POST "$API/releases/$RID/assets?name=EmbyX-en.apk" \
|
||||
-H "$AUTH" -F "attachment=@EmbyX-en.apk"
|
||||
|
||||
echo "发布完成: http://192.168.1.66:3000/adminym/embyx/releases/tag/$TAG"
|
||||
|
||||
+49
-1
@@ -364,7 +364,7 @@
|
||||
class="bg-gray-800/40 p-5 rounded-xl border border-gray-700/50 space-y-5 text-sm text-gray-300 leading-relaxed">
|
||||
<div class="flex items-center">
|
||||
<h3 class="text-white font-bold text-2xl tracking-tight">📱 EmbyX</h3>
|
||||
<span
|
||||
<span id="appVersionBadge"
|
||||
class="ml-2 px-2 py-0.5 bg-primary/20 text-primary text-xs font-bold rounded-full border border-primary/30">v1.1</span>
|
||||
</div>
|
||||
|
||||
@@ -675,6 +675,9 @@
|
||||
setTimeout(() => this.toggleModal('profilePage', true), 500);
|
||||
}
|
||||
|
||||
// Check for updates (silent mode, only changes badge color)
|
||||
this.checkUpdate();
|
||||
|
||||
// ─── Branding Egg ───
|
||||
console.log(
|
||||
"%c EmbyX %c Designed by Juneix %c",
|
||||
@@ -684,6 +687,51 @@
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check GitHub for updates
|
||||
*/
|
||||
async checkUpdate() {
|
||||
const badge = document.getElementById('appVersionBadge');
|
||||
if (!badge) return;
|
||||
|
||||
// Rate limiting: check once every 24 hours
|
||||
const lastCheck = localStorage.getItem('embyx_last_update_check');
|
||||
const now = Date.now();
|
||||
if (lastCheck && now - parseInt(lastCheck) < 24 * 60 * 60 * 1000) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/repos/juneix/EmbyX/releases/latest');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
localStorage.setItem('embyx_last_update_check', now.toString());
|
||||
|
||||
const latestTag = data.tag_name; // e.g. v1.2
|
||||
const latestVersion = latestTag.replace('v', '').trim();
|
||||
const currentVersion = this.config.appVersion;
|
||||
|
||||
// Semantic version comparison
|
||||
const isNewer = (latest, current) => {
|
||||
const l = latest.split('.').map(Number);
|
||||
const c = current.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(l.length, c.length); i++) {
|
||||
if ((l[i] || 0) > (c[i] || 0)) return true;
|
||||
if ((l[i] || 0) < (c[i] || 0)) return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (isNewer(latestVersion, currentVersion)) {
|
||||
// New version found: change to orange and enable click
|
||||
badge.classList.remove('bg-primary/20', 'text-primary', 'border-primary/30');
|
||||
badge.classList.add('bg-orange-500/20', 'text-orange-500', 'border-orange-500/30', 'cursor-pointer', 'transition-all', 'hover:opacity-80', 'active:scale-95');
|
||||
badge.title = 'New version available, click to download';
|
||||
badge.onclick = () => window.open('https://github.com/juneix/EmbyX/releases', '_blank');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Update check failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
cacheDOM() {
|
||||
const ids = [
|
||||
|
||||
+3
-3
@@ -7,10 +7,10 @@
|
||||
"build:android": "npx cap sync android"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/android": "^5.0.0",
|
||||
"@capacitor/core": "^5.0.0"
|
||||
"@capacitor/android": "^8.3.1",
|
||||
"@capacitor/core": "^8.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@capacitor/cli": "^5.0.0"
|
||||
"@capacitor/cli": "^8.3.1"
|
||||
}
|
||||
}
|
||||
+218
-5
@@ -220,6 +220,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="syncConfirmModal"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/20 hidden backdrop-blur-sm transition-opacity duration-300">
|
||||
<div
|
||||
class="bg-black/50 rounded-2xl p-6 w-[85%] max-w-sm border border-gray-700/50 shadow-2xl backdrop-blur-sm text-center">
|
||||
<div
|
||||
class="w-14 h-14 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-primary/20">
|
||||
<i data-lucide="refresh-cw" class="stroke-primary w-6 h-6"></i>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-white mb-2">同步进度</h3>
|
||||
<p id="syncFileName" class="text-gray-400 text-sm mb-6 leading-relaxed"></p>
|
||||
<div class="flex space-x-3">
|
||||
<button id="cancelSyncBtn"
|
||||
class="flex-1 px-4 py-2.5 rounded-xl bg-gray-800 text-gray-300 font-medium active:scale-95 transition-all">忽略</button>
|
||||
<button id="confirmSyncBtn"
|
||||
class="flex-1 px-4 py-2.5 rounded-xl bg-primary text-white font-medium active:scale-95 transition-all">同步</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="deleteConfirmModal"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/20 hidden backdrop-blur-sm transition-opacity duration-300">
|
||||
<div
|
||||
@@ -377,8 +396,8 @@
|
||||
class="bg-gray-800/40 p-5 rounded-xl border border-gray-700/50 space-y-5 text-sm text-gray-300 leading-relaxed">
|
||||
<div class="flex items-center">
|
||||
<h3 class="text-white font-bold text-2xl tracking-tight">📱 EmbyX</h3>
|
||||
<span
|
||||
class="ml-2 px-2 py-0.5 bg-primary/20 text-primary text-xs font-bold rounded-full border border-primary/30">v1.1</span>
|
||||
<span id="appVersionBadge"
|
||||
class="ml-2 px-2 py-0.5 bg-primary/20 text-primary text-xs font-bold rounded-full border border-primary/30">v1.2</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -394,7 +413,14 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-gray-300">🔮 播放性能</h4>
|
||||
<h4 class="text-gray-300 flex items-center">
|
||||
🔮 播放性能
|
||||
<a href="./info.html"
|
||||
class="ml-2 text-gray-500 hover:text-primary transition-colors active:scale-95"
|
||||
title="检测播放能力">
|
||||
<i data-lucide="cpu" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</h4>
|
||||
<ul class="list-disc list-inside space-y-1 text-gray-400 text-xs mt-2">
|
||||
<li>原生 HTML5 播放器,新设备体验最佳</li>
|
||||
<li>老设备需服务器转码,<del>不如放到**上回收(没打钱</del> 😂</li>
|
||||
@@ -651,7 +677,7 @@
|
||||
// 检查当前是否有任何弹窗/面板处于打开状态
|
||||
// 这种状态下不应触发自动清屏
|
||||
isAnyModalOpen() {
|
||||
const modals = ['mediaInfoModal', 'deleteConfirmModal', 'profilePage', 'libraryModal'];
|
||||
const modals = ['mediaInfoModal', 'deleteConfirmModal', 'syncConfirmModal', 'profilePage', 'libraryModal'];
|
||||
return modals.some(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return false;
|
||||
@@ -705,6 +731,9 @@
|
||||
setTimeout(() => this.toggleModal('profilePage', true), 500);
|
||||
}
|
||||
|
||||
// 检查版本更新 (静默模式,仅修改个人中心胶囊颜色)
|
||||
this.checkUpdate();
|
||||
|
||||
// ─── 注入项目签名彩蛋 (Branding Egg) ───
|
||||
console.log(
|
||||
"%c EmbyX %c 原创设计@谢週五 %c",
|
||||
@@ -714,6 +743,52 @@
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 GitHub 更新
|
||||
* 逻辑:对比本地版本与 GitHub 最新 Release
|
||||
*/
|
||||
async checkUpdate() {
|
||||
const badge = document.getElementById('appVersionBadge');
|
||||
if (!badge) return;
|
||||
|
||||
// 频率限制:24小时检查一次
|
||||
const lastCheck = localStorage.getItem('embyx_last_update_check');
|
||||
const now = Date.now();
|
||||
if (lastCheck && now - parseInt(lastCheck) < 24 * 60 * 60 * 1000) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/repos/juneix/EmbyX/releases/latest');
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
localStorage.setItem('embyx_last_update_check', now.toString());
|
||||
|
||||
const latestTag = data.tag_name; // 例如 v1.2
|
||||
const latestVersion = latestTag.replace('v', '').trim();
|
||||
const currentVersion = this.config.appVersion;
|
||||
|
||||
// 语义化版本对比函数 (支持 1.1 < 1.1.2 等)
|
||||
const isNewer = (latest, current) => {
|
||||
const l = latest.split('.').map(Number);
|
||||
const c = current.split('.').map(Number);
|
||||
for (let i = 0; i < Math.max(l.length, c.length); i++) {
|
||||
if ((l[i] || 0) > (c[i] || 0)) return true;
|
||||
if ((l[i] || 0) < (c[i] || 0)) return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (isNewer(latestVersion, currentVersion)) {
|
||||
// 发现新版本:变橙并激活点击
|
||||
badge.classList.remove('bg-primary/20', 'text-primary', 'border-primary/30');
|
||||
badge.classList.add('bg-orange-500/20', 'text-orange-500', 'border-orange-500/30', 'cursor-pointer', 'transition-all', 'hover:opacity-80', 'active:scale-95');
|
||||
badge.title = '发现新版本,点击前往下载';
|
||||
badge.onclick = () => window.open('https://github.com/juneix/EmbyX/releases', '_blank');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Update check failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
cacheDOM() {
|
||||
const ids = [
|
||||
@@ -723,6 +798,7 @@
|
||||
'favoriteBtn', 'scaleBtn', 'muteBtn', 'fullscreenBtn', 'deleteBtn',
|
||||
'mediaInfoModal', 'mediaInfoContent', 'closeMediaInfoBtn',
|
||||
'deleteConfirmModal', 'confirmDeleteBtn', 'cancelDeleteBtn', 'deleteFileName',
|
||||
'syncConfirmModal', 'confirmSyncBtn', 'cancelSyncBtn', 'syncFileName',
|
||||
'configServer', 'configUser', 'configPwd', 'configStatic', 'configAutoplay', 'configShortDrama', 'configDeleteMode'
|
||||
];
|
||||
ids.forEach(id => this.dom[id] = document.getElementById(id));
|
||||
@@ -993,6 +1069,18 @@
|
||||
} else {
|
||||
this.state.videos = data.Items;
|
||||
this.state.currentIndex = 0;
|
||||
|
||||
// 短剧模式:尝试从本地恢复进度
|
||||
if (this.config.shortDrama && !isAppend && this.state.currentLibraryId) {
|
||||
const lastId = localStorage.getItem(`emby_last_id_${this.state.currentLibraryId}`);
|
||||
if (lastId) {
|
||||
const savedIndex = this.state.videos.findIndex(v => v.Id === lastId);
|
||||
if (savedIndex !== -1) {
|
||||
this.state.currentIndex = savedIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保存媒体库真实总数(API 返回,不受 Limit 影响)供分页显示
|
||||
this.state.totalCount = data.TotalRecordCount || 0;
|
||||
|
||||
@@ -1006,7 +1094,11 @@
|
||||
this.renderGridView();
|
||||
} else {
|
||||
this.renderSlides();
|
||||
this.loadVideo(0);
|
||||
this.loadVideo(this.state.currentIndex);
|
||||
// 短剧模式:异步检查云端是否有更新的进度
|
||||
if (this.config.shortDrama && !isAppend) {
|
||||
setTimeout(() => this.checkCloudSync(), 1500);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isLoadMore || isAppend) {
|
||||
@@ -1408,6 +1500,15 @@
|
||||
|
||||
if (!videoEl || !videoData) return;
|
||||
|
||||
// 短剧模式:记录当前本地播放进度标识
|
||||
if (this.config.shortDrama && this.state.currentLibraryId) {
|
||||
const now = new Date().toISOString();
|
||||
localStorage.setItem(`emby_last_id_${this.state.currentLibraryId}`, videoData.Id);
|
||||
localStorage.setItem(`emby_last_date_${this.state.currentLibraryId}`, now);
|
||||
// 异步同步到云端
|
||||
this.saveCloudSync(videoData.Id, this.state.currentLibraryId, this.state.currentLibraryType, now);
|
||||
}
|
||||
|
||||
// renderBuffer 已赋值 src,处理未匹配时的修正
|
||||
const src = this.getVideoSrc(videoData.Id, videoData);
|
||||
if (videoEl.src !== src) {
|
||||
@@ -1751,6 +1852,110 @@
|
||||
}).catch(() => { });
|
||||
}
|
||||
|
||||
// --- 续播与同步逻辑 (方案 A: DisplayPreferences) ---
|
||||
|
||||
async saveCloudSync(itemId, libId, libType, date) {
|
||||
try {
|
||||
const { server, token, userId } = this.config;
|
||||
const url = `${server}/emby/DisplayPreferences/EmbyX-Resume-Drama?userId=${userId}&api_key=${token}`;
|
||||
|
||||
const prefs = {
|
||||
Id: "EmbyX-Resume-Drama",
|
||||
CustomPrefs: {
|
||||
lastId: itemId,
|
||||
libId: libId,
|
||||
libType: libType,
|
||||
date: date,
|
||||
deviceName: this.config.deviceName
|
||||
}
|
||||
};
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(prefs)
|
||||
}).catch(() => { });
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
async checkCloudSync() {
|
||||
if (!this.config.shortDrama) return;
|
||||
|
||||
try {
|
||||
const { server, token, userId } = this.config;
|
||||
const url = `${server}/emby/DisplayPreferences/EmbyX-Resume-Drama?userId=${userId}&api_key=${token}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
const cloud = data.CustomPrefs;
|
||||
if (!cloud || !cloud.lastId || !cloud.date) return;
|
||||
|
||||
const localId = localStorage.getItem(`emby_last_id_${this.state.currentLibraryId}`);
|
||||
const localDateStr = localStorage.getItem(`emby_last_date_${this.state.currentLibraryId}`) || '';
|
||||
|
||||
const cloudDate = new Date(cloud.date).getTime();
|
||||
const localDate = localDateStr ? new Date(localDateStr).getTime() : 0;
|
||||
|
||||
// 只有云端进度更新,且 ID 不同时才提示
|
||||
if (cloudDate > localDate && cloud.lastId !== localId) {
|
||||
// 获取设备名称与视频名称
|
||||
let deviceDisplay = cloud.deviceName || "其他设备";
|
||||
let displayName = "更新的剧集";
|
||||
|
||||
if (cloud.libId === this.state.currentLibraryId) {
|
||||
const item = this.state.videos.find(v => v.Id === cloud.lastId);
|
||||
if (item) displayName = item.Name;
|
||||
}
|
||||
|
||||
this.dom.syncFileName.textContent = `检测到 ${deviceDisplay} 播放至:${displayName}`;
|
||||
this.state._pendingSyncItem = cloud;
|
||||
this.toggleModal('syncConfirmModal', true);
|
||||
lucide.createIcons();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('云端同步检测失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async applyCloudSync() {
|
||||
const cloud = this.state._pendingSyncItem;
|
||||
if (!cloud) return;
|
||||
|
||||
this.toggleModal('syncConfirmModal', false);
|
||||
this.showToast('正在同步进度...');
|
||||
|
||||
// 更新本地缓存,确保后续逻辑一致
|
||||
localStorage.setItem(`emby_last_id_${cloud.libId}`, cloud.lastId);
|
||||
localStorage.setItem(`emby_last_date_${cloud.libId}`, cloud.date);
|
||||
|
||||
if (cloud.libId !== this.state.currentLibraryId) {
|
||||
// 跨库跳转
|
||||
this.state.currentLibraryId = cloud.libId;
|
||||
this.state.currentLibraryType = cloud.libType;
|
||||
localStorage.setItem('emby_lib_id', cloud.libId);
|
||||
localStorage.setItem('emby_lib_type', cloud.libType);
|
||||
|
||||
// 重新加载新库
|
||||
this.state.startIndex = 0;
|
||||
await this.fetchVideos(0, false);
|
||||
this.showToast('✅ 已跨库同步');
|
||||
} else {
|
||||
// 同库跳转
|
||||
const index = this.state.videos.findIndex(v => v.Id === cloud.lastId);
|
||||
if (index !== -1) {
|
||||
this.state.currentIndex = index;
|
||||
this.renderSlides();
|
||||
this.loadVideo(index);
|
||||
this.showToast('✅ 进度已同步');
|
||||
} else {
|
||||
// 如果当前分页没找到,重刷该库
|
||||
this.state.startIndex = 0;
|
||||
await this.fetchVideos(0, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reportCapabilities() {
|
||||
const { server, token } = this.config;
|
||||
if (!server || !token) return;
|
||||
@@ -2494,6 +2699,8 @@
|
||||
|
||||
this.dom.confirmDeleteBtn.onclick = () => this.executeDelete();
|
||||
this.dom.cancelDeleteBtn.onclick = () => this.toggleModal('deleteConfirmModal', false);
|
||||
this.dom.confirmSyncBtn.onclick = () => this.applyCloudSync();
|
||||
this.dom.cancelSyncBtn.onclick = () => this.toggleModal('syncConfirmModal', false);
|
||||
|
||||
this.dom.deleteConfirmModal.onclick = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
@@ -2501,6 +2708,12 @@
|
||||
}
|
||||
};
|
||||
|
||||
this.dom.syncConfirmModal.onclick = (e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
this.toggleModal('syncConfirmModal', false);
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('progressBarContainer').onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
+523
@@ -0,0 +1,523 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>EmbyX - 浏览器播放能力检测</title>
|
||||
<!-- 沿用 index.html 的 Tailwind CDN -->
|
||||
<script src="https://cdn.tailwindcss.com" data-cfasync="false"></script>
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<script data-cfasync="false">
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#52B54B', // Emby绿
|
||||
secondary: '#EE3152' // 抖音红
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['PingFang SC', 'Helvetica Neue', 'Arial', 'sans-serif']
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style type="text/tailwindcss">
|
||||
@layer utilities {
|
||||
.glass-card {
|
||||
@apply bg-gray-800/40 border border-gray-700/50 backdrop-blur-sm rounded-xl;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body class="bg-black text-white font-sans select-none min-h-screen pb-10">
|
||||
<!-- Header -->
|
||||
<div class="fixed top-0 left-0 w-full h-[calc(3.5rem+env(safe-area-inset-top))] border-b border-gray-700/50 bg-black/50 backdrop-blur-sm z-10 pt-[env(safe-area-inset-top)]">
|
||||
<div class="flex items-center justify-between h-14 px-4 relative">
|
||||
<button onclick="history.back()" class="text-gray-400 w-10 h-10 flex items-center justify-center active:text-gray-300 rounded-full">
|
||||
<i data-lucide="chevron-left" class="w-6 h-6"></i>
|
||||
</button>
|
||||
<span class="font-bold text-lg">浏览器播放能力检测</span>
|
||||
<div class="w-10"></div> <!-- 占位 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="pt-[calc(4.5rem+env(safe-area-inset-top))] p-4 space-y-6">
|
||||
|
||||
<!-- 检测结果上半部分(4列网格) -->
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-gray-400 text-xs font-bold uppercase tracking-widest ml-1">检测结果</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
|
||||
<!-- 浏览器外壳 -->
|
||||
<div class="glass-card p-4 flex flex-col items-center justify-center text-center space-y-2">
|
||||
<div class="w-12 h-12 bg-gray-700/30 rounded-full flex items-center justify-center">
|
||||
<i data-lucide="compass" class="w-7 h-7 text-primary" id="shell-icon"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 font-medium">浏览器外壳</div>
|
||||
<div class="text-sm font-bold mt-0.5" id="shell-name">正在检测...</div>
|
||||
<div class="text-[10px] text-gray-400 font-mono mt-0.5" id="shell-version">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 浏览器内核 -->
|
||||
<div class="glass-card p-4 flex flex-col items-center justify-center text-center space-y-2">
|
||||
<div class="w-12 h-12 bg-gray-700/30 rounded-full flex items-center justify-center">
|
||||
<i data-lucide="cpu" class="w-7 h-7 text-blue-500" id="kernel-icon"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 font-medium">浏览器内核</div>
|
||||
<div class="text-sm font-bold mt-0.5" id="kernel-name">正在检测...</div>
|
||||
<div class="text-[10px] text-gray-400 font-mono mt-0.5" id="kernel-version">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作系统 -->
|
||||
<div class="glass-card p-4 flex flex-col items-center justify-center text-center space-y-2">
|
||||
<div class="w-12 h-12 bg-gray-700/30 rounded-full flex items-center justify-center">
|
||||
<i data-lucide="monitor" class="w-7 h-7 text-purple-500" id="os-icon"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 font-medium">操作系统</div>
|
||||
<div class="text-sm font-bold mt-0.5" id="os-name">正在检测...</div>
|
||||
<div class="text-[10px] text-gray-400 font-mono mt-0.5" id="os-version">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 终端设备 -->
|
||||
<div class="glass-card p-4 flex flex-col items-center justify-center text-center space-y-2">
|
||||
<div class="w-12 h-12 bg-gray-700/30 rounded-full flex items-center justify-center">
|
||||
<i data-lucide="smartphone" class="w-7 h-7 text-orange-500" id="device-icon"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs text-gray-500 font-medium">终端设备</div>
|
||||
<div class="text-sm font-bold mt-0.5" id="device-name">正在检测...</div>
|
||||
<div class="text-[10px] text-gray-400 font-mono mt-0.5" id="device-info">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Raw User-Agent -->
|
||||
<div class="glass-card p-3 text-[10px] text-gray-500 font-mono break-all" id="ua-string">
|
||||
正在获取 User-Agent...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 体验度评估 -->
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-gray-400 text-xs font-bold uppercase tracking-widest ml-1">体验评估</h2>
|
||||
<div class="glass-card p-4 bg-primary/10 border-primary/20 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-10 h-10 bg-primary/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<i data-lucide="zap" class="w-5 h-5 text-primary"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-primary">EmbyX 体验度预估</h3>
|
||||
<p class="text-xs text-gray-300 mt-0.5" id="exp-summary">老古董设备,建议换机或转码。</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 评分放在右侧,使用超大字重 -->
|
||||
<div class="text-3xl font-black text-primary pr-2" id="exp-score">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 浏览器其他参数检测(下半部分) -->
|
||||
<div class="space-y-2">
|
||||
<h2 class="text-gray-400 text-xs font-bold uppercase tracking-widest ml-1">视频播放与解码能力</h2>
|
||||
<div class="glass-card overflow-hidden">
|
||||
<table class="w-full text-left text-xs border-collapse">
|
||||
<thead class="bg-gray-700/30 text-gray-400 text-[10px] uppercase font-bold">
|
||||
<tr>
|
||||
<th class="py-2.5 px-4 whitespace-nowrap text-center">序号</th>
|
||||
<th class="py-2.5 px-2">功能特性</th>
|
||||
<th class="py-2.5 px-2 text-center">支持状态</th>
|
||||
<th class="py-2.5 px-4 hidden md:table-cell">详细说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700/30 text-gray-300" id="capability-table">
|
||||
<!-- 动态填充 -->
|
||||
<tr>
|
||||
<td colspan="4" class="py-4 text-center text-gray-500">正在扫描媒体能力...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 脚本逻辑 -->
|
||||
<script>
|
||||
// 初始化 Lucide 图标
|
||||
lucide.createIcons();
|
||||
|
||||
// 基础元素
|
||||
const $ = id => document.getElementById(id);
|
||||
const ua = navigator.userAgent;
|
||||
$('ua-string').textContent = ua;
|
||||
|
||||
// --- 1. 基础环境检测 ---
|
||||
function detectEnvironment() {
|
||||
let shell = "Unknown", shellVer = "-";
|
||||
let kernel = "Unknown", kernelVer = "-";
|
||||
let os = "Unknown", osVer = "-";
|
||||
|
||||
// 浏览器外壳简单识别
|
||||
if (/edg\//i.test(ua)) { shell = "Edge"; shellVer = ua.match(/Edg\/([\d.]+)/)?.[1] || "-"; }
|
||||
else if (/chrome/i.test(ua)) { shell = "Chrome"; shellVer = ua.match(/Chrome\/([\d.]+)/)?.[1] || "-"; }
|
||||
else if (/safari/i.test(ua) && !/chrome/i.test(ua)) { shell = "Safari"; shellVer = ua.match(/Version\/([\d.]+)/)?.[1] || "-"; }
|
||||
else if (/firefox/i.test(ua)) { shell = "Firefox"; shellVer = ua.match(/Firefox\/([\d.]+)/)?.[1] || "-"; }
|
||||
|
||||
// 内核
|
||||
if (/webkit/i.test(ua)) { kernel = "WebKit"; kernelVer = ua.match(/AppleWebKit\/([\d.]+)/)?.[1] || "-"; }
|
||||
else if (/gecko/i.test(ua) && !/webkit/i.test(ua)) { kernel = "Gecko"; kernelVer = ua.match(/rv:([\d.]+)/)?.[1] || "-"; }
|
||||
|
||||
// 操作系统
|
||||
if (/macintosh|mac os x/i.test(ua)) { os = "Mac OS"; osVer = ua.match(/Mac OS X ([\d_]+)/)?.[1]?.replace(/_/g, '.') || "-"; }
|
||||
else if (/windows/i.test(ua)) { os = "Windows"; osVer = ua.match(/Windows NT ([\d.]+)/)?.[1] || "-"; }
|
||||
else if (/android/i.test(ua)) {
|
||||
os = "Android";
|
||||
osVer = ua.match(/Android ([\d.]+)/)?.[1] || "-";
|
||||
if (osVer === "10") osVer = "10+";
|
||||
}
|
||||
else if (/iphone|ipad/i.test(ua)) { os = "iOS"; osVer = ua.match(/OS ([\d_]+)/)?.[1]?.replace(/_/g, '.') || "-"; }
|
||||
|
||||
$('shell-name').textContent = shell;
|
||||
$('shell-version').textContent = shellVer;
|
||||
$('kernel-name').textContent = kernel;
|
||||
$('kernel-version').textContent = kernelVer;
|
||||
$('os-name').textContent = os;
|
||||
$('os-version').textContent = osVer;
|
||||
|
||||
// 动态更换图标
|
||||
if (shell === "Safari") $('shell-icon').setAttribute('data-lucide', 'compass');
|
||||
if (os === "iOS" || os === "Mac OS") $('os-icon').setAttribute('data-lucide', 'apple');
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// --- 2. 终端设备检测 ---
|
||||
function getGPU() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
if (!gl) return null;
|
||||
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
if (!ext) return null;
|
||||
return gl.getParameter(ext.UNMASKED_RENDERER_WEBGL);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getGPUClean() {
|
||||
const gpu = getGPU();
|
||||
if (!gpu) return null;
|
||||
return gpu.replace('ANGLE (', '')
|
||||
.replace(/\(/g, '')
|
||||
.replace(/\)/g, '')
|
||||
.replace(/TM/i, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.split(',')[0]
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function detectDevice() {
|
||||
let deviceName = "Unknown Device";
|
||||
let brand = "Other";
|
||||
|
||||
// 1. 同步保底识别 (参考 SidePod)
|
||||
if (/iPhone|iPad|iPod/.test(ua)) brand = 'Apple';
|
||||
else if (/Xiaomi|Redmi|MIUI|POCO/i.test(ua) || /2312|2210|2109|2405/i.test(ua)) brand = 'Xiaomi';
|
||||
else if (/Samsung/i.test(ua)) brand = 'Samsung';
|
||||
else if (/Huawei/i.test(ua)) brand = 'Huawei';
|
||||
else if (/Honor/i.test(ua)) brand = 'Honor';
|
||||
else if (/OPPO/i.test(ua)) brand = 'OPPO';
|
||||
else if (/vivo/i.test(ua)) brand = 'vivo';
|
||||
|
||||
if (brand !== 'Other') {
|
||||
deviceName = brand + " 设备";
|
||||
}
|
||||
|
||||
// 2. 异步 Client Hints 深度识别 (SidePod 方案)
|
||||
if (navigator.userAgentData) {
|
||||
try {
|
||||
const hints = await navigator.userAgentData.getHighEntropyValues(['model']);
|
||||
if (hints.model) {
|
||||
deviceName = hints.model;
|
||||
// 从获取到的真实型号中二次判定品牌
|
||||
if (/Xiaomi|Redmi|POCO|Mi /i.test(deviceName) || /2312|2210|2109|2405/i.test(deviceName)) {
|
||||
brand = 'Xiaomi';
|
||||
} else if (/Samsung/i.test(deviceName)) {
|
||||
brand = 'Samsung';
|
||||
} else if (/Huawei/i.test(deviceName)) {
|
||||
brand = 'Huawei';
|
||||
} else if (/Honor/i.test(deviceName)) {
|
||||
brand = 'Honor';
|
||||
} else if (/OPPO/i.test(deviceName)) {
|
||||
brand = 'OPPO';
|
||||
} else if (/vivo/i.test(deviceName)) {
|
||||
brand = 'vivo';
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
} else {
|
||||
// 3. 从 UA 提取型号(针对安卓未冻结或老设备)
|
||||
if (/android/i.test(ua)) {
|
||||
const match = ua.match(/\(([^)]+)\)/);
|
||||
if (match && match[1]) {
|
||||
const parts = match[1].split(';');
|
||||
for (let p of parts) {
|
||||
p = p.trim();
|
||||
// 修复:安卓 10+ 冻结 UA 会返回 "K",必须过滤掉,否则会显示为设备名
|
||||
if (/^linux/i.test(p) || /^u$/i.test(p) || /^android/i.test(p) || /^[a-z]{2}-[a-z]{2}$/i.test(p) || /wv/.test(p) || p === 'K') continue;
|
||||
deviceName = p.split('Build/')[0].trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (/iphone/i.test(ua)) {
|
||||
deviceName = "iPhone";
|
||||
} else if (/ipad/i.test(ua)) {
|
||||
deviceName = "iPad";
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 终极绕过:如果依然无法获取真实型号(如 HTTP 环境下 Client Hints 失效)
|
||||
// 使用 WebGL 获取 GPU 型号作为真实设备信息的补充
|
||||
if (deviceName === "Unknown Device" || deviceName === "K" || deviceName.includes("设备")) {
|
||||
const gpuClean = getGPUClean();
|
||||
if (gpuClean) {
|
||||
deviceName = gpuClean;
|
||||
}
|
||||
}
|
||||
|
||||
// 保持与其他选项一致,只有两行
|
||||
$('device-name').textContent = deviceName;
|
||||
$('device-info').textContent = brand;
|
||||
}
|
||||
|
||||
// --- 3. 视频播放与解码能力检测 ---
|
||||
async function checkMediaCapabilities() {
|
||||
const video = document.createElement('video');
|
||||
|
||||
async function getCodecStatus(codec) {
|
||||
const canPlay = video.canPlayType(codec);
|
||||
if (canPlay === "") return 'none';
|
||||
|
||||
let status = (canPlay === 'probably') ? 'full' : 'partial';
|
||||
|
||||
if (navigator.mediaCapabilities && navigator.mediaCapabilities.decodingInfo) {
|
||||
try {
|
||||
const result = await navigator.mediaCapabilities.decodingInfo({
|
||||
type: 'file',
|
||||
video: {
|
||||
contentType: codec,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
bitrate: 1000,
|
||||
framerate: 30
|
||||
}
|
||||
});
|
||||
|
||||
if (result.supported) {
|
||||
if (result.powerEfficient) {
|
||||
return 'full';
|
||||
} else {
|
||||
return 'partial';
|
||||
}
|
||||
}
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
const isIPhone = /iphone/i.test(ua);
|
||||
|
||||
// 主流芯片解码能力对照表 (2021-2026 5年跨度)
|
||||
// 用于校验和修正浏览器 API 的假阴性报告
|
||||
// 1. Adreno 660 (Snapdragon 888): 支持 HEVC, AV1仅软解
|
||||
// https://www.qualcomm.com/smartphones/products/8-series/snapdragon-888-5g-mobile-platform
|
||||
// 2. Adreno 730 (Snapdragon 8 Gen 1): 支持 HEVC, AV1仅软解
|
||||
// https://www.qualcomm.com/smartphones/products/8-series/snapdragon-8-gen-1-mobile-platform
|
||||
// 3. Adreno 740 (Snapdragon 8 Gen 2): 支持 AV1, HEVC
|
||||
// https://www.qualcomm.com/smartphones/products/8-series/snapdragon-8-gen-2-mobile-platform
|
||||
// 4. Adreno 750 (Snapdragon 8 Gen 3): 支持 AV1, HEVC
|
||||
// https://www.qualcomm.com/smartphones/products/8-series/snapdragon-8-gen-3-mobile-platform
|
||||
// 5. Apple A14/A15/A16: 支持 HEVC, AV1仅软解
|
||||
// https://support.apple.com/en-us/111876
|
||||
// 6. Apple A17 Pro / A18: 支持 AV1, HEVC
|
||||
// https://support.apple.com/en-us/111829
|
||||
// 7. Mali-G78 (Dimensity 1200): 支持 AV1, HEVC
|
||||
// https://i.mediatek.com/dimensity-1200
|
||||
// 8. Mali-G710 (Dimensity 9000): 支持 AV1, HEVC
|
||||
// https://www.mediatek.com/products/smartphones-2/mediatek-dimensity-9000
|
||||
// 9. Mali-G720 (Dimensity 9300): 支持 AV1, HEVC
|
||||
// https://www.mediatek.com/products/smartphones-2/mediatek-dimensity-9300
|
||||
|
||||
const gpuDatabase = {
|
||||
"Adreno 660": { hevc: 'full', av1: 'partial' },
|
||||
"Adreno 730": { hevc: 'full', av1: 'partial' },
|
||||
"Adreno 740": { hevc: 'full', av1: 'full' },
|
||||
"Adreno 750": { hevc: 'full', av1: 'full' },
|
||||
"Apple A14": { hevc: 'full', av1: 'partial' },
|
||||
"Apple A15": { hevc: 'full', av1: 'partial' },
|
||||
"Apple A16": { hevc: 'full', av1: 'partial' },
|
||||
"Apple A17 Pro": { hevc: 'full', av1: 'full' },
|
||||
"Apple A18": { hevc: 'full', av1: 'full' },
|
||||
"Mali-G78": { hevc: 'full', av1: 'full' },
|
||||
"Mali-G710": { hevc: 'full', av1: 'full' },
|
||||
"Mali-G715": { hevc: 'full', av1: 'full' },
|
||||
"Mali-G720": { hevc: 'full', av1: 'full' }
|
||||
};
|
||||
|
||||
const gpuName = getGPUClean();
|
||||
const hwSpecs = gpuDatabase[gpuName] || {};
|
||||
|
||||
const checks = [
|
||||
{
|
||||
name: "HTML5 Video 基础",
|
||||
desc: "支持原生网页播放",
|
||||
check: async () => !!video.canPlayType ? 'full' : 'none'
|
||||
},
|
||||
{
|
||||
name: "MSE (媒体源扩展)",
|
||||
desc: "流媒体切片播放基础",
|
||||
check: async () => 'MediaSource' in window ? 'full' : 'none'
|
||||
},
|
||||
{
|
||||
name: "H.264 (AVC) 解码",
|
||||
desc: "最通用视频格式",
|
||||
check: async () => await getCodecStatus('video/mp4; codecs="avc1.42E01E"')
|
||||
},
|
||||
{
|
||||
name: "H.265 (HEVC) 解码",
|
||||
desc: "高画质格式,防发热卡顿",
|
||||
check: async () => {
|
||||
if (isIPhone) return 'partial'; // 针对 iPhone 强制灰色对勾
|
||||
|
||||
const browserStatus = (await getCodecStatus('video/mp4; codecs="hev1.1.6.L93.B0"') === 'full' || await getCodecStatus('video/mp4; codecs="hvc1"') === 'full') ? 'full' : 'none';
|
||||
const hwStatus = hwSpecs.hevc || 'none';
|
||||
|
||||
// 只有两者都确认完全支持,才给绿色对勾
|
||||
if (browserStatus === 'full' && hwStatus === 'full') return 'full';
|
||||
// 只要有一方不支持或部分支持,一律显示灰色对勾(不加额外注释)
|
||||
if (browserStatus === 'full' || hwStatus === 'full' || hwStatus === 'partial') return 'partial';
|
||||
return 'none';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "AV1 解码",
|
||||
desc: "下一代极高压缩率格式",
|
||||
check: async () => {
|
||||
if (isIPhone) return 'partial'; // 针对 iPhone 强制灰色对勾
|
||||
|
||||
const browserStatus = await getCodecStatus('video/mp4; codecs="av01.0.05M.08"');
|
||||
const hwStatus = hwSpecs.av1 || 'none';
|
||||
|
||||
// 只有两者都确认完全支持,才给绿色对勾
|
||||
if (browserStatus === 'full' && hwStatus === 'full') return 'full';
|
||||
// 只要有一方不支持或部分支持,一律显示灰色对勾(不加额外注释)
|
||||
if (browserStatus === 'full' || hwStatus === 'full' || hwStatus === 'partial') return 'partial';
|
||||
// 针对老设备,即便 API 返回不支持,也给予“灰色对勾(保底软解)”,因为很多设备支持低规格软解
|
||||
return 'partial';
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "VP9 解码",
|
||||
desc: "Google高清视频格式",
|
||||
check: async () => await getCodecStatus('video/webm; codecs="vp9"')
|
||||
},
|
||||
{
|
||||
name: "HDR 画面支持",
|
||||
desc: "高动态范围色彩显示",
|
||||
check: async () => {
|
||||
const isHDR = window.matchMedia('(video-dynamic-range: high)').matches || window.matchMedia('(dynamic-range: high)').matches;
|
||||
// 苹果设备 + P3屏幕 = 绝对支持 HDR 播放
|
||||
const isAppleP3 = (/macintosh|mac os x|iphone|ipad/i.test(ua) && window.matchMedia('(color-gamut: p3)').matches);
|
||||
return (isHDR || isAppleP3) ? 'full' : 'none';
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const tbody = $('capability-table');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
let hasHEVC = false, hasHEVCPartial = false;
|
||||
let hasAV1 = false, hasAV1Partial = false;
|
||||
|
||||
for (let i = 0; i < checks.length; i++) {
|
||||
const item = checks[i];
|
||||
const status = await item.check();
|
||||
|
||||
if (item.name.includes("H.265")) {
|
||||
if (status === 'full') hasHEVC = true;
|
||||
if (status === 'partial') hasHEVCPartial = true;
|
||||
}
|
||||
if (item.name.includes("AV1")) {
|
||||
if (status === 'full') hasAV1 = true;
|
||||
if (status === 'partial') hasAV1Partial = true;
|
||||
}
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = "hover:bg-gray-700/10 transition-colors align-middle";
|
||||
|
||||
let iconHtml = '';
|
||||
if (status === 'full') {
|
||||
iconHtml = `<i data-lucide="check" class="w-5 h-5 text-primary"></i>`;
|
||||
} else if (status === 'partial') {
|
||||
iconHtml = `<i data-lucide="check" class="w-5 h-5 text-gray-500"></i>`;
|
||||
} else {
|
||||
iconHtml = `<i data-lucide="x" class="w-5 h-5 text-secondary"></i>`;
|
||||
}
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="py-2 px-4 text-center text-gray-500 font-mono">${i + 1}</td>
|
||||
<td class="py-2 px-2">
|
||||
<div class="font-bold text-sm">${item.name}</div>
|
||||
<div class="text-[10px] text-gray-500 md:hidden mt-0.5">${item.desc}</div>
|
||||
</td>
|
||||
<td class="py-2 px-2 text-center">
|
||||
<div class="flex justify-center">
|
||||
${iconHtml}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 px-4 text-gray-400 text-xs hidden md:table-cell">${item.desc}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
|
||||
// 评估体验度(调整评分逻辑)
|
||||
let score = "一般";
|
||||
let tip = "老古董设备,建议换机或转码。";
|
||||
|
||||
const hevcSupported = hasHEVC || hasHEVCPartial;
|
||||
const av1Supported = hasAV1 || hasAV1Partial;
|
||||
|
||||
if (hasHEVC && hasAV1) {
|
||||
score = "完美";
|
||||
tip = "支持全格式硬解,体验极佳。";
|
||||
} else if (hasHEVC || (hevcSupported && av1Supported)) {
|
||||
// 有 HEVC 硬解,或者 HEVC 和 AV1 都有支持(哪怕是软解),判定为良好
|
||||
score = "良好";
|
||||
tip = "支持主流格式,体验流畅。";
|
||||
}
|
||||
|
||||
$('exp-summary').textContent = tip;
|
||||
$('exp-score').textContent = score;
|
||||
}
|
||||
|
||||
// 运行所有检测
|
||||
detectEnvironment();
|
||||
detectDevice();
|
||||
checkMediaCapabilities();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user