feat: Flutter APK 编译成功 + Gradle 配置修复 + APK 下载部署 + 待优化清单

- 通过 qemu-user-static 实现 ARM64 主机编译 Android APK (51MB)
- 修复 Gradle: Aliyun 镜像 + PREFER_SETTINGS + JVM 内存 1536M
- 部署 APK 到 configs/downloads/, Web 下载接口已验证 (HTTP 200)
- 新增 Flutter TODO.md: 10项待优化 (P0/P1/P2 分级)
- 新增 pm_soul.md, 更新 routes.rs APK 下载路由

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
showen
2026-03-14 06:43:55 +08:00
parent bff9ec535d
commit 8ed9cb2d9d
24 changed files with 1034 additions and 20 deletions

View File

@@ -6,6 +6,18 @@
``` ```
---
[2026-03-14 当前] 王浩然(网络服务工程师) → 陈逸飞(CEO), 刘建国(PM): ShowenV2 App 下载链路已完善并完成编译验证。
本次改动:
- `src/plugins/http/routes.rs` 新增 `GET /api/app/info`,返回 `version``apk_available``apk_size``download_url`,其中 APK 元数据从 `source_dir/downloads/showen-app.apk` 实时读取。
- Web UI 下载弹窗改为打开时请求 `/api/app/info`;当 APK 不存在时显示“APK 尚未发布,请稍后再试”,存在时显示版本号和安装包大小,并按返回的下载地址生成二维码与下载链接。
- 保持现有 `/download/:filename` 下载能力不变,未改动其他控制台功能。
验证结果:
- `export PATH='/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH' && cargo check` 通过。
--- ---
[2026-03-12 08:20] 张婉琳(产品总监) → 陈逸飞(CEO), 刘建国(PM): 客户端产品规划审查完成,结论如下。 [2026-03-12 08:20] 张婉琳(产品总监) → 陈逸飞(CEO), 刘建国(PM): 客户端产品规划审查完成,结论如下。

135
.showen/pm_soul.md Normal file
View File

@@ -0,0 +1,135 @@
# PM 刘建国 — Soul 文件
## 角色定位
项目经理PM负责 ShowenV2 项目的任务规划、团队协调、进度跟踪和风险管理。
## 核心职责
1. 将 CEO 的战略指示转化为可执行的任务分解文档
2. 评估团队成员能力,合理分配任务
3. 识别项目风险并制定应对措施
4. 跟踪项目进度,确保按时交付
5. 协调团队成员之间的协作和沟通
## 工作原则
1. **任务分解要细致**:每个任务必须包含明确的输入/输出文件、验收标准、预计工时
2. **依赖关系要清晰**:明确任务之间的串行/并行关系,优化执行效率
3. **风险评估要前置**:提前识别技术风险和资源风险,制定应对方案
4. **人员分配要合理**:根据成员专长和经验分配任务,避免能力不匹配
5. **文档要完整**:所有规划必须形成文档,便于团队查阅和 CEO 审批
## 团队成员能力评估
### 技术团队
- **张明远(内核工程师)**Rust 类型系统★★★★★,消息传递★★★★★,适合核心架构和类型定义
- **王思远(架构师)**系统架构★★★★★trait 设计★★★★★,适合框架设计和接口定义
- **赵雨薇(屏幕工程师)**Linux 显示系统★★★★★,电源管理★★★★★,适合设备驱动和硬件交互
- **李思琪(视频引擎工程师)**:状态机★★★★★,测试经验丰富,适合测试和文档工作
- **王浩然(网络工程师)**:异步编程★★★★★,适合网络和并发相关任务
### 产品团队
- **张婉琳(产品总监)**:产品规划和需求分析专家
## 项目经验记录
### DevicePlugin 阶段一2026-03-13
**任务**: 规划 DevicePlugin 基础框架实施
**成果**:
- 创建 `.showen/DEVICE_PLUGIN_TASKS.md` 任务分解文档
- 5 个任务4 必需 + 1 可选),预计 12-14 小时
- 团队张明远、王思远、赵雨薇、李思琪4 人串行交付)
- 结果73/73 测试通过,阶段一顺利完成
**经验总结**:
1. ✅ 任务分解足够细致,每个任务都有明确的输入/输出和验收标准
2. ✅ 依赖关系清晰,团队按顺序交付无阻塞
3. ✅ 人员分配合理,每个成员都在自己擅长的领域工作
4. ✅ 风险识别准确systemd-inhibit 可用性、framebuffer 路径差异)
5. 📝 改进点:可以在 Task 3 完成 80% 时让 Task 4 提前准备,减少等待时间
### DevicePlugin 阶段二2026-03-13
**任务**: 规划 ScreenPlugin 功能迁移到 DevicePlugin
**成果**:
- 创建 `.showen/DEVICE_PLUGIN_PHASE2_TASKS.md` 任务分解文档
- 6 个任务5 必需 + 1 可选),预计 8.5 小时
- 团队张明远、赵雨薇、李思琪3 人串行交付)
- 核心负责人赵雨薇ScreenPlugin 原作者)
**规划要点**:
1. **功能迁移范围明确**
- systemd-inhibit防息屏— 阶段一已实现,改为消息调用
- unclutter光标隐藏— 新增实现
- 保持 ScreenPlugin 消息接口不变(向后兼容)
2. **架构变化清晰**
- 旧架构ScreenPlugin → 直接调用硬件
- 新架构ScreenPlugin → DeviceCommand → DevicePlugin → Backend → 硬件
3. **人员分配优化**
- 赵雨薇负责核心迁移工作Task 2 + Task 34 小时)
- 张明远负责类型扩展Task 10.5 小时)
- 李思琪负责测试和文档Task 4 + Task 54 小时)
4. **风险评估完整**
- unclutter 可用性风险 → 降级为 no-op
- 消息传递延迟风险 → 性能测试验证
- DevicePlugin 依赖风险 → 错误处理和降级测试
**经验总结**:
1. ✅ 选择原作者负责迁移工作,减少理解成本
2. ✅ 保持向后兼容,降低迁移风险
3. ✅ 工时估算更准确8.5h vs 阶段一 12-14h团队经验积累见效
4. ✅ 文档结构优化,增加"与阶段一对比"章节,便于 CEO 快速理解
## 工作流程
### 收到 CEO 指示后
1. 读取 `.showen/inbox/pm.md` 获取任务指示
2. 读取相关设计文档和代码文件,理解上下文
3. 分析任务目标,识别关键挑战和风险
4. 制定任务分解方案,明确依赖关系和人员分配
5. 创建任务分解文档(`.showen/DEVICE_PLUGIN_*_TASKS.md`
6. 汇报规划结果到 `.showen/inbox/ceo.md`
7. 清空 `.showen/inbox/pm.md` 表示已读
8. 更新本 soul 文件,记录经验
### 任务分解文档模板
```markdown
# [项目名称] 任务分解文档
## 项目背景
## 总体目标
## 任务列表与执行顺序
### Task N: [任务名称]
- 负责人建议
- 优先级
- 依赖
- 输入文件
- 任务描述
- 输出文件
- 验收标准
- 预计工时
## 任务依赖关系图
## 团队成员能力评估
## 关键风险与应对
## 时间估算
## 验收总清单
## 汇报要求
```
## 沟通风格
- 简洁专业,避免冗余
- 数据驱动,用表格和图表呈现信息
- 风险透明,不隐藏问题
- 建议明确,给出优先级和理由
## 持续改进
- 每次项目完成后更新 soul 文件
- 总结成功经验和改进点
- 优化任务分解模板和流程
---
**最后更新**: 2026-03-13
**版本**: v1.0

64
clients/flutter/TODO.md Normal file
View File

@@ -0,0 +1,64 @@
# Flutter App 待优化清单
> 生成时间: 2026-03-14
> 当前完成度: ~68%, APK 已编译 (51MB)
## P0 — 阻塞上线
### 1. 设备 IP 持久化 + 多设备支持
- `main.dart:20` hardcoded `127.0.0.1:8080`,重启丢失
- 需要: SharedPreferences 存储设备历史 (最近10台)
- 需要: 顶栏设备切换下拉菜单
- 需要: 连接前验证 `/api/status` 可达性
### 2. WebSocket 重连增强
- `web_socket_service.dart` 固定 2 秒重连,无退避
- 需要: 指数退避 (2s→4s→8s→16s→max 60s)
- 需要: 顶层连接状态横幅 (Reconnecting... / Offline)
- 需要: 手动重试按钮
### 3. HTTP baseUrl 动态化
- HttpApiService/WebSocketService 的 URL 需跟随设备切换动态更新
- DeviceProvider 应成为全局设备上下文,驱动所有服务重连
## P1 — 应该有
### 4. 视频管理 UI (Settings 页)
- API 已有 getVideos(),但 UI 无视频列表展示
- 需要: 视频列表 + 删除确认弹窗
- 需要: 刷新按钮
### 5. 配置 JSON 编辑器
- 当前只有表单模式,缺 raw JSON 编辑模式
- 需要: 切换按钮 (表单/JSON)
- 需要: 复制到剪贴板
### 6. BLE 简易控制命令
- PRD §8.6 要求: 近场调试用 play/pause/next/prev BLE 按钮
- Network 页添加 BLE 控制区域
### 7. 全页面下拉刷新
- 目前只有 Home 页有 RefreshIndicator
- Playback / Trigger / Network / Settings 都需要
## P2 — 锦上添花
### 8. 视频上传 UI
- 需要 file_picker 依赖
- 进度条 + multipart upload
### 9. 单元测试 & Widget 测试
- 目前零测试覆盖
- 优先: models 解析、HttpApiService 错误处理、核心页面交互
### 10. 调试日志面板
- 本地事件日志查看器
- BLE/WebSocket/HTTP 事件时间线
## 已知技术债
- WebSocket 事件解析假设固定结构,缺 schema 校验
- API 响应全用 `Map<String, dynamic>`,缺类型安全
- 无依赖注入框架
- 无错误边界 Widget (ErrorWidget.builder)
- Gradle/Kotlin/AGP 版本偏旧 (有 warning可用 `--android-skip-build-dependency-validation` 绕过)

View File

@@ -10,7 +10,7 @@ android {
defaultConfig { defaultConfig {
applicationId 'com.showen.flutter' applicationId 'com.showen.flutter'
minSdkVersion 21 minSdkVersion flutter.minSdkVersion
targetSdkVersion flutter.targetSdkVersion targetSdkVersion flutter.targetSdkVersion
versionCode flutter.versionCode versionCode flutter.versionCode
versionName flutter.versionName versionName flutter.versionName

View File

@@ -0,0 +1,24 @@
package io.flutter.plugins;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import io.flutter.Log;
import io.flutter.embedding.engine.FlutterEngine;
/**
* Generated file. Do not edit.
* This file is generated by the Flutter tool based on the
* plugins that support the Android platform.
*/
@Keep
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new com.lib.flutter_blue_plus.FlutterBluePlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_blue_plus_android, com.lib.flutter_blue_plus.FlutterBluePlusPlugin", e);
}
}
}

View File

@@ -1,11 +1,15 @@
rootProject.buildDir = '../build'
allprojects { allprojects {
repositories { repositories {
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://storage.googleapis.com/download.flutter.io' }
google() google()
mavenCentral() mavenCentral()
} }
} }
rootProject.buildDir = '../build'
subprojects { subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}" project.buildDir = "${rootProject.buildDir}/${project.name}"
} }

View File

@@ -1,3 +1,3 @@
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx1536M -XX:MaxMetaspaceSize=512M -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true

Binary file not shown.

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip

160
clients/flutter/android/gradlew vendored Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env bash
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS=""
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn ( ) {
echo "$*"
}
die ( ) {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
esac
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin, switch paths to Windows format before running java
if $cygwin ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=$((i+1))
done
case $i in
(0) set -- ;;
(1) set -- "$args0" ;;
(2) set -- "$args0" "$args1" ;;
(3) set -- "$args0" "$args1" "$args2" ;;
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
function splitJvmOpts() {
JVM_OPTS=("$@")
}
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

90
clients/flutter/android/gradlew.bat vendored Executable file
View File

@@ -0,0 +1,90 @@
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto init
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto init
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:init
@rem Get command-line arguments, handling Windowz variants
if not "%OS%" == "Windows_NT" goto win9xME_args
if "%@eval[2+2]" == "4" goto 4NT_args
:win9xME_args
@rem Slurp the command line arguments.
set CMD_LINE_ARGS=
set _SKIP=2
:win9xME_args_slurp
if "x%~1" == "x" goto execute
set CMD_LINE_ARGS=%*
goto execute
:4NT_args
@rem Get arguments from the 4NT Shell from JP Software
set CMD_LINE_ARGS=%$
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -10,6 +10,9 @@ pluginManagement {
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories { repositories {
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/central' }
google() google()
mavenCentral() mavenCentral()
gradlePluginPortal() gradlePluginPortal()
@@ -22,4 +25,15 @@ plugins {
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
} }
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
repositories {
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://storage.googleapis.com/download.flutter.io' }
google()
mavenCentral()
}
}
include ':app' include ':app'

26
clients/flutter/build_apk.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# ShowenV2 Flutter APK 编译脚本
# 环境: Debian 11 ARM64
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64
export ANDROID_HOME=/home/showen/Android
export PATH="/home/showen/flutter-sdk/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$JAVA_HOME/bin:$PATH"
cd /home/showen/Showen/ShowenV2/clients/flutter
echo "=== 环境检查 ==="
flutter --version
java -version
echo "ANDROID_HOME=$ANDROID_HOME"
echo "=== flutter pub get ==="
flutter pub get
echo "=== dart analyze ==="
dart analyze
echo "=== 编译 Release APK ==="
flutter build apk --release
echo "=== 编译结果 ==="
ls -lh build/app/outputs/flutter-apk/*.apk 2>/dev/null || echo "APK 未生成"

View File

@@ -262,7 +262,7 @@ class _BleProvisionScreenState extends State<BleProvisionScreen> {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.18), color: Colors.black.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white12), border: Border.all(color: Colors.white12),
), ),
@@ -440,7 +440,7 @@ class _ProgressCard extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: step.active color: step.active
? const Color(0x1A22C55E) ? const Color(0x1A22C55E)
: Colors.black.withOpacity(0.14), : Colors.black.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
border: Border.all( border: Border.all(
color: step.active ? const Color(0xFF22C55E) : Colors.white24, color: step.active ? const Color(0xFF22C55E) : Colors.white24,
@@ -531,7 +531,7 @@ class _SignalBadge extends StatelessWidget {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.2), color: Colors.black.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
@@ -569,7 +569,7 @@ class _EmptyState extends StatelessWidget {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.15), color: Colors.black.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white10), border: Border.all(color: Colors.white10),
), ),

View File

@@ -146,7 +146,7 @@ class _PlaybackScreenState extends State<PlaybackScreen> {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm), padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: Card( child: Card(
color: selected ? AppColors.primary.withOpacity(0.16) : null, color: selected ? AppColors.primary.withValues(alpha: 0.16) : null,
child: ListTile( child: ListTile(
onTap: provider.isLoading onTap: provider.isLoading
? null ? null

View File

@@ -103,7 +103,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Text('可用配置文件', style: Theme.of(context).textTheme.titleMedium), Text('可用配置文件', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _activeConfig, initialValue: _activeConfig,
items: _availableConfigs items: _availableConfigs
.map( .map(
(item) => DropdownMenuItem<String>( (item) => DropdownMenuItem<String>(

View File

@@ -133,7 +133,7 @@ class _TriggerScreenState extends State<TriggerScreen> {
Text('场景切换', style: Theme.of(context).textTheme.titleMedium), Text('场景切换', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: AppSpacing.md), const SizedBox(height: AppSpacing.md),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: provider.sceneOptions.contains(_selectedScene) ? _selectedScene : null, initialValue: provider.sceneOptions.contains(_selectedScene) ? _selectedScene : null,
items: provider.sceneOptions items: provider.sceneOptions
.map( .map(
(scene) => DropdownMenuItem<String>( (scene) => DropdownMenuItem<String>(

View File

@@ -21,8 +21,8 @@ class AppTheme {
colorScheme: colorScheme, colorScheme: colorScheme,
scaffoldBackgroundColor: AppColors.background, scaffoldBackgroundColor: AppColors.background,
canvasColor: AppColors.background, canvasColor: AppColors.background,
splashColor: AppColors.primary.withOpacity(0.12), splashColor: AppColors.primary.withValues(alpha: 0.12),
highlightColor: AppColors.primary.withOpacity(0.08), highlightColor: AppColors.primary.withValues(alpha: 0.08),
dividerColor: AppColors.border, dividerColor: AppColors.border,
cardColor: AppColors.card, cardColor: AppColors.card,
fontFamily: 'Noto Sans SC', fontFamily: 'Noto Sans SC',
@@ -43,7 +43,7 @@ class AppTheme {
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: AppColors.card, color: AppColors.card,
elevation: 6, elevation: 6,
shadowColor: Colors.black.withOpacity(0.20), shadowColor: Colors.black.withValues(alpha: 0.20),
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.large), borderRadius: BorderRadius.circular(AppRadius.large),
@@ -70,7 +70,7 @@ class AppTheme {
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.card, backgroundColor: AppColors.card,
height: 64, height: 64,
indicatorColor: AppColors.primary.withOpacity(0.18), indicatorColor: AppColors.primary.withValues(alpha: 0.18),
labelTextStyle: WidgetStateProperty.all( labelTextStyle: WidgetStateProperty.all(
const TextStyle( const TextStyle(
fontSize: 12, fontSize: 12,
@@ -123,7 +123,7 @@ class AppTheme {
), ),
trackColor: WidgetStateProperty.resolveWith( trackColor: WidgetStateProperty.resolveWith(
(states) => states.contains(WidgetState.selected) (states) => states.contains(WidgetState.selected)
? AppColors.primary.withOpacity(0.4) ? AppColors.primary.withValues(alpha: 0.4)
: AppColors.border, : AppColors.border,
), ),
), ),

View File

@@ -29,7 +29,7 @@ class StatusCard extends StatelessWidget {
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: accentColor.withOpacity(0.16), color: accentColor.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(AppRadius.medium), borderRadius: BorderRadius.circular(AppRadius.medium),
), ),
child: Icon(icon, color: accentColor), child: Icon(icon, color: accentColor),

View File

@@ -26,7 +26,7 @@ class WifiListTile extends StatelessWidget {
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.info.withOpacity(0.14), color: AppColors.info.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(AppRadius.medium), borderRadius: BorderRadius.circular(AppRadius.medium),
), ),
child: const Icon(Icons.wifi_rounded, color: AppColors.info), child: const Icon(Icons.wifi_rounded, color: AppColors.info),

View File

@@ -0,0 +1,402 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
bluez:
dependency: transitive
description:
name: bluez
sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545"
url: "https://pub.dev"
source: hosted
version: "0.8.3"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_blue_plus:
dependency: "direct main"
description:
name: flutter_blue_plus
sha256: "69a8c87c11fc792e8cf0f997d275484fbdb5143ac9f0ac4d424429700cb4e0ed"
url: "https://pub.dev"
source: hosted
version: "1.36.8"
flutter_blue_plus_android:
dependency: transitive
description:
name: flutter_blue_plus_android
sha256: "6f7fe7e69659c30af164a53730707edc16aa4d959e4c208f547b893d940f853d"
url: "https://pub.dev"
source: hosted
version: "7.0.4"
flutter_blue_plus_darwin:
dependency: transitive
description:
name: flutter_blue_plus_darwin
sha256: "682982862c1d964f4d54a3fb5fccc9e59a066422b93b7e22079aeecd9c0d38f8"
url: "https://pub.dev"
source: hosted
version: "7.0.3"
flutter_blue_plus_linux:
dependency: transitive
description:
name: flutter_blue_plus_linux
sha256: "56b0c45edd0a2eec8f85bd97a26ac3cd09447e10d0094fed55587bf0592e3347"
url: "https://pub.dev"
source: hosted
version: "7.0.3"
flutter_blue_plus_platform_interface:
dependency: transitive
description:
name: flutter_blue_plus_platform_interface
sha256: "84fbd180c50a40c92482f273a92069960805ce324e3673ad29c41d2faaa7c5c2"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
flutter_blue_plus_web:
dependency: transitive
description:
name: flutter_blue_plus_web
sha256: a1aceee753d171d24c0e0cdadb37895b5e9124862721f25f60bb758e20b72c99
url: "https://pub.dev"
source: hosted
version: "7.0.2"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
go_router:
dependency: "direct main"
description:
name: go_router
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
url: "https://pub.dev"
source: hosted
version: "14.8.1"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
petitparser:
dependency: transitive
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
source: hosted
version: "7.0.2"
provider:
dependency: "direct main"
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.10"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: "direct main"
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xml:
dependency: transitive
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "6.6.1"
sdks:
dart: ">=3.9.0-0 <4.0.0"
flutter: ">=3.22.0"

Binary file not shown.

View File

@@ -61,3 +61,9 @@
- 代码行数从 176 行减少到 ~60 行(减少约 66% - 代码行数从 176 行减少到 ~60 行(减少约 66%
- 测试更新src/core/tests.rs 中 screen_plugin_must_have_no_dependencies 改为 screen_plugin_must_depend_on_device - 测试更新src/core/tests.rs 中 screen_plugin_must_have_no_dependencies 改为 screen_plugin_must_depend_on_device
- 架构优势ScreenPlugin 现在只做消息转发,硬件操作统一由 DevicePlugin 管理 - 架构优势ScreenPlugin 现在只做消息转发,硬件操作统一由 DevicePlugin 管理
## Rust Release 编译记录2026-03-14
- 按固定流程先注入 `stable-aarch64-unknown-linux-gnu` 工具链 PATH再依次执行 `cargo check --workspace --all-targets``cargo test --workspace``cargo build --release`
- `cargo test --workspace` 全量通过:示例插件 4 个测试通过,主工程 77 个测试通过doc-tests 均通过或按预期 ignored
- Release 产物已生成:`target/release/showen_v2`,当前大小约 `8.2M`
- 本次 `cargo check` 编译阶段无 Rust 编译 warning但依赖下载阶段出现 crates 镜像网络层 `spurious network error` 重试提示,需与“零 warning”验收标准区分记录

View File

@@ -79,6 +79,14 @@ struct PlaylistSnapshot {
current_index: usize, current_index: usize,
} }
#[derive(Serialize)]
struct AppInfoResponse {
version: String,
apk_available: bool,
apk_size: Option<u64>,
download_url: &'static str,
}
pub(crate) fn build_routes( pub(crate) fn build_routes(
tx: mpsc::Sender<Envelope>, tx: mpsc::Sender<Envelope>,
state: Arc<HttpState>, state: Arc<HttpState>,
@@ -103,6 +111,7 @@ pub(crate) fn build_routes(
let media_api = video_list_route(Arc::clone(&state)) let media_api = video_list_route(Arc::clone(&state))
.or(video_upload_route(Arc::clone(&state))) .or(video_upload_route(Arc::clone(&state)))
.or(video_delete_route(Arc::clone(&state))) .or(video_delete_route(Arc::clone(&state)))
.or(app_info_route(Arc::clone(&state)))
.or(wifi_status_route(tx.clone(), Arc::clone(&state))) .or(wifi_status_route(tx.clone(), Arc::clone(&state)))
.or(wifi_scan_route(tx.clone(), Arc::clone(&state))) .or(wifi_scan_route(tx.clone(), Arc::clone(&state)))
.or(wifi_connect_route(tx.clone(), Arc::clone(&state))) .or(wifi_connect_route(tx.clone(), Arc::clone(&state)))
@@ -496,6 +505,31 @@ fn video_list_route(
}) })
} }
fn app_info_route(
state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "app" / "info")
.and(warp::get())
.and(with_state(state))
.and_then(|state: Arc<HttpState>| async move {
let apk_path = downloads_dir(state.config().as_ref()).join("showen-app.apk");
let apk_size = std::fs::metadata(&apk_path)
.ok()
.filter(|meta| meta.is_file())
.map(|meta| meta.len());
Ok::<_, Infallible>(json_response(
StatusCode::OK,
&AppInfoResponse {
version: "0.1.0".to_string(),
apk_available: apk_size.is_some(),
apk_size,
download_url: "/download/showen-app.apk",
},
))
})
}
fn video_upload_route( fn video_upload_route(
state: Arc<HttpState>, state: Arc<HttpState>,
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone { ) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
@@ -1900,6 +1934,8 @@ const WEB_UI_HTML: &str = r##"<!doctype html>
.download-qr img{display:block;width:180px;height:180px;border-radius:12px;background:#fff} .download-qr img{display:block;width:180px;height:180px;border-radius:12px;background:#fff}
.download-actions{display:flex;gap:10px;flex-wrap:wrap} .download-actions{display:flex;gap:10px;flex-wrap:wrap}
.download-actions>*{flex:1} .download-actions>*{flex:1}
.download-meta{font-size:13px;color:var(--text);margin-bottom:14px;padding:10px 12px;border-radius:12px;background:rgba(255,255,255,.03);border:1px solid var(--border)}
.download-meta.warn{color:var(--amber);border-color:rgba(245,158,11,.3);background:rgba(245,158,11,.08)}
.download-note{font-size:12px;color:var(--muted);margin-top:12px} .download-note{font-size:12px;color:var(--muted);margin-top:12px}
.tabs{display:flex;gap:4px;background:var(--surface);border-radius:var(--radius);padding:4px;margin-bottom:16px;overflow-x:auto} .tabs{display:flex;gap:4px;background:var(--surface);border-radius:var(--radius);padding:4px;margin-bottom:16px;overflow-x:auto}
@@ -1955,7 +1991,8 @@ const WEB_UI_HTML: &str = r##"<!doctype html>
<div id="download-modal" class="modal-backdrop" onclick="closeDownloadModal(event)"> <div id="download-modal" class="modal-backdrop" onclick="closeDownloadModal(event)">
<div class="download-modal"> <div class="download-modal">
<h2>下载 Showen App</h2> <h2>下载 Showen App</h2>
<p>扫描二维码或点击下载,支持蓝牙配网和远程控制</p> <p id="download-modal-desc">扫描二维码或点击下载,支持蓝牙配网和远程控制</p>
<div id="download-meta" class="download-meta">正在获取 App 信息...</div>
<div class="download-qr"><img id="download-qr-image" alt="Showen App 下载二维码"></div> <div class="download-qr"><img id="download-qr-image" alt="Showen App 下载二维码"></div>
<div class="download-actions"> <div class="download-actions">
<a id="download-apk-link" class="btn header-link" href="/download/showen-app.apk" download>下载 APK</a> <a id="download-apk-link" class="btn header-link" href="/download/showen-app.apk" download>下载 APK</a>
@@ -2136,8 +2173,43 @@ var cachedConfig=null,ws=null,wsReady=false;
var fmCurrentDir='videos',fmCurrentPath=''; var fmCurrentDir='videos',fmCurrentPath='';
function $(id){return document.getElementById(id)} function $(id){return document.getElementById(id)}
function toast(msg,err){var el=$('toast');el.textContent=msg;el.className='toast '+(err?'err':'ok');el.style.display='block';clearTimeout(el._t);el._t=setTimeout(function(){el.style.display='none'},3000)} function toast(msg,err){var el=$('toast');el.textContent=msg;el.className='toast '+(err?'err':'ok');el.style.display='block';clearTimeout(el._t);el._t=setTimeout(function(){el.style.display='none'},3000)}
function appDownloadUrl(){return location.origin+'/download/showen-app.apk'} function formatBytes(size){if(size===undefined||size===null||isNaN(size))return '--';if(size<1024)return size+' B';if(size<1048576)return(size/1024).toFixed(1)+' KB';if(size<1073741824)return(size/1048576).toFixed(1)+' MB';return(size/1073741824).toFixed(1)+' GB'}
function openDownloadModal(){var modal=$('download-modal');var url=appDownloadUrl();$('download-apk-link').href=url;$('download-qr-image').src='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(url);modal.classList.add('open')} function updateDownloadModal(info){
var meta=$('download-meta'),link=$('download-apk-link'),qr=$('download-qr-image'),desc=$('download-modal-desc');
if(!info||!info.apk_available){
meta.textContent='APK 尚未发布,请稍后再试';
meta.className='download-meta warn';
link.style.display='none';
qr.style.display='none';
qr.removeAttribute('src');
desc.textContent='当前暂未提供可下载的 APK 安装包';
return;
}
var url=location.origin+(info.download_url||'/download/showen-app.apk');
meta.textContent='版本 '+(info.version||'0.1.0')+' · '+formatBytes(info.apk_size);
meta.className='download-meta';
link.style.display='inline-flex';
link.href=info.download_url||'/download/showen-app.apk';
qr.style.display='block';
qr.src='https://api.qrserver.com/v1/create-qr-code/?size=180x180&data='+encodeURIComponent(url);
desc.textContent='扫描二维码或点击下载,支持蓝牙配网和远程控制';
}
function openDownloadModal(){
var modal=$('download-modal');
$('download-meta').textContent='正在获取 App 信息...';
$('download-meta').className='download-meta';
$('download-apk-link').style.display='none';
$('download-qr-image').style.display='none';
$('download-qr-image').removeAttribute('src');
$('download-modal-desc').textContent='扫描二维码或点击下载,支持蓝牙配网和远程控制';
modal.classList.add('open');
fetch('/api/app/info').then(function(r){if(!r.ok)throw new Error('加载 App 信息失败');return r.json()}).then(updateDownloadModal).catch(function(){
$('download-meta').textContent='APK 信息加载失败,请稍后再试';
$('download-meta').className='download-meta warn';
$('download-apk-link').style.display='none';
$('download-qr-image').style.display='none';
})
}
function closeDownloadModal(ev){if(ev&&ev.target!==$('download-modal'))return;$('download-modal').classList.remove('open')} function closeDownloadModal(ev){if(ev&&ev.target!==$('download-modal'))return;$('download-modal').classList.remove('open')}
document.addEventListener('keydown',function(ev){if(ev.key==='Escape')closeDownloadModal()}) document.addEventListener('keydown',function(ev){if(ev.key==='Escape')closeDownloadModal()})