From bff9ec535dc02bae26d0a75eda7d8a9dd395c47a Mon Sep 17 00:00:00 2001 From: showen Date: Sat, 14 Mar 2026 02:09:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Flutter=20=E5=AE=A2=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=20App=20+=20Web=20UI=20APK=20=E4=B8=8B=E8=BD=BD=E5=85=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Flutter 跨平台客户端项目 (clients/flutter/) - 29 个 Dart 文件: 服务层/状态管理/5个页面/BLE配网 - BLE 蓝牙配网: 扫描设备、写入WiFi凭据、配网状态监听 - HTTP API 客户端: 覆盖全部端点 (播放/场景/WiFi/视频/配置/文件/插件) - WebSocket 实时通信: 事件流 + 自动重连 - 暗色主题 Material 3 UI, 中文界面 - Android 配置: minSdkVersion 21, BLE/网络权限 - PRD 产品需求文档 + 开发任务看板 - Web UI 添加 APK 下载入口 (routes.rs) - 下载弹窗 + 二维码 + /download/{filename} 静态文件路由 - BLE 插件增加自动重连循环 (ble/mod.rs) - BLE 默认设备名修正为 'Showen' (config.rs) Co-Authored-By: Claude Opus 4.6 --- .showen/TEAM_CHAT.md | 17 + clients/flutter/.gitignore | 12 + clients/flutter/PRD.md | 411 +++++++ clients/flutter/TASKS.md | 164 +++ clients/flutter/android/app/build.gradle | 37 + .../android/app/src/main/AndroidManifest.xml | 34 + .../kotlin/com/showen/flutter/MainActivity.kt | 5 + .../app/src/main/res/values/styles.xml | 6 + clients/flutter/android/build.gradle | 15 + clients/flutter/android/gradle.properties | 3 + clients/flutter/android/settings.gradle | 25 + clients/flutter/lib/main.dart | 130 +++ clients/flutter/lib/models/api_response.dart | 22 + clients/flutter/lib/models/app_event.dart | 24 + clients/flutter/lib/models/ble_models.dart | 69 ++ clients/flutter/lib/models/ble_status.dart | 23 + clients/flutter/lib/models/device_status.dart | 54 + clients/flutter/lib/models/player_status.dart | 68 ++ clients/flutter/lib/models/video_item.dart | 23 + clients/flutter/lib/models/wifi_network.dart | 21 + clients/flutter/lib/models/wifi_status.dart | 23 + .../flutter/lib/providers/ble_provider.dart | 196 ++++ .../lib/providers/device_provider.dart | 233 ++++ .../lib/providers/player_provider.dart | 184 ++++ .../flutter/lib/providers/wifi_provider.dart | 132 +++ clients/flutter/lib/screens/app_shell.dart | 51 + .../lib/screens/ble_provision_screen.dart | 597 +++++++++++ clients/flutter/lib/screens/home_screen.dart | 146 +++ .../flutter/lib/screens/network_screen.dart | 201 ++++ .../flutter/lib/screens/playback_screen.dart | 182 ++++ .../flutter/lib/screens/settings_screen.dart | 338 ++++++ .../flutter/lib/screens/trigger_screen.dart | 194 ++++ clients/flutter/lib/services/ble_service.dart | 215 ++++ .../lib/services/http_api_service.dart | 503 +++++++++ .../lib/services/web_socket_service.dart | 172 +++ clients/flutter/lib/theme/app_colors.dart | 39 + clients/flutter/lib/theme/app_theme.dart | 192 ++++ .../flutter/lib/widgets/control_button.dart | 46 + clients/flutter/lib/widgets/status_card.dart | 55 + .../flutter/lib/widgets/wifi_list_tile.dart | 40 + clients/flutter/pubspec.yaml | 25 + src/core/config.rs | 2 +- src/plugins/ble/gatt.rs | 21 +- src/plugins/ble/mod.rs | 29 +- src/plugins/http/routes.rs | 999 ++++++++++++++++-- 45 files changed, 5903 insertions(+), 75 deletions(-) create mode 100644 clients/flutter/.gitignore create mode 100644 clients/flutter/PRD.md create mode 100644 clients/flutter/TASKS.md create mode 100644 clients/flutter/android/app/build.gradle create mode 100644 clients/flutter/android/app/src/main/AndroidManifest.xml create mode 100644 clients/flutter/android/app/src/main/kotlin/com/showen/flutter/MainActivity.kt create mode 100644 clients/flutter/android/app/src/main/res/values/styles.xml create mode 100644 clients/flutter/android/build.gradle create mode 100644 clients/flutter/android/gradle.properties create mode 100644 clients/flutter/android/settings.gradle create mode 100644 clients/flutter/lib/main.dart create mode 100644 clients/flutter/lib/models/api_response.dart create mode 100644 clients/flutter/lib/models/app_event.dart create mode 100644 clients/flutter/lib/models/ble_models.dart create mode 100644 clients/flutter/lib/models/ble_status.dart create mode 100644 clients/flutter/lib/models/device_status.dart create mode 100644 clients/flutter/lib/models/player_status.dart create mode 100644 clients/flutter/lib/models/video_item.dart create mode 100644 clients/flutter/lib/models/wifi_network.dart create mode 100644 clients/flutter/lib/models/wifi_status.dart create mode 100644 clients/flutter/lib/providers/ble_provider.dart create mode 100644 clients/flutter/lib/providers/device_provider.dart create mode 100644 clients/flutter/lib/providers/player_provider.dart create mode 100644 clients/flutter/lib/providers/wifi_provider.dart create mode 100644 clients/flutter/lib/screens/app_shell.dart create mode 100644 clients/flutter/lib/screens/ble_provision_screen.dart create mode 100644 clients/flutter/lib/screens/home_screen.dart create mode 100644 clients/flutter/lib/screens/network_screen.dart create mode 100644 clients/flutter/lib/screens/playback_screen.dart create mode 100644 clients/flutter/lib/screens/settings_screen.dart create mode 100644 clients/flutter/lib/screens/trigger_screen.dart create mode 100644 clients/flutter/lib/services/ble_service.dart create mode 100644 clients/flutter/lib/services/http_api_service.dart create mode 100644 clients/flutter/lib/services/web_socket_service.dart create mode 100644 clients/flutter/lib/theme/app_colors.dart create mode 100644 clients/flutter/lib/theme/app_theme.dart create mode 100644 clients/flutter/lib/widgets/control_button.dart create mode 100644 clients/flutter/lib/widgets/status_card.dart create mode 100644 clients/flutter/lib/widgets/wifi_list_tile.dart create mode 100644 clients/flutter/pubspec.yaml diff --git a/.showen/TEAM_CHAT.md b/.showen/TEAM_CHAT.md index bb67087..4825c75 100644 --- a/.showen/TEAM_CHAT.md +++ b/.showen/TEAM_CHAT.md @@ -2508,3 +2508,20 @@ ScreenPlugin 已重构为 DevicePlugin 的 thin wrapper,但 main.rs 中没有 P0 问题已解决,DevicePlugin 现已完全集成到 ShowenV2 运行时!🎉 +--- + +[2026-03-14 当前] 刘建国(PM) → 陈逸飞(CEO), 张婉琳(产品总监), Flutter 项目相关成员: Flutter 客户端立项文档已启动并完成首版产出。 + +本次输出: +- 已创建 `clients/flutter/PRD.md`:定义 Flutter App 的产品目标、用户场景、五大页面、BLE 配网协议、HTTP/WebSocket 能力范围、非功能需求、风险与验收标准。 +- 已创建 `clients/flutter/TASKS.md`:按基础架构、设备发现、BLE、HTTP、WebSocket、页面开发、配置/视频管理、测试发布 8 个模块拆解任务,标注优先级、依赖与预计工时。 + +关键决策: +- Flutter App 定位为 Phase 2 移动端主交付,优先完成“首次接入 + 高频控制 + 实时状态”三条主链路。 +- 设备发现同时支持 BLE 扫描与手动输入 IP,避免首次联网与局域网复连割裂。 +- BLE 客户端实现按 read + notify 双模式兼容,降低服务端 notify 语义不完整带来的联调风险。 +- 设置页中的视频上传标记为 P2,首发优先保证配置查看/提交、视频列表与删除能力。 + +项目判断: +- 当前文档已足够支撑 Flutter 技术方案设计、UI 细化与开发排期。 +- 后续需要在正式开发前冻结客户端共享数据模型,并安排一次 API/WebSocket/BLE 联调校准。 diff --git a/clients/flutter/.gitignore b/clients/flutter/.gitignore new file mode 100644 index 0000000..e9a71a6 --- /dev/null +++ b/clients/flutter/.gitignore @@ -0,0 +1,12 @@ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub/ +.pub-cache/ +/build/ +/coverage/ +android/.gradle/ +android/local.properties +ios/Flutter/Flutter.framework +ios/Flutter/Flutter.podspec diff --git a/clients/flutter/PRD.md b/clients/flutter/PRD.md new file mode 100644 index 0000000..48231fc --- /dev/null +++ b/clients/flutter/PRD.md @@ -0,0 +1,411 @@ +# Showen Flutter App PRD + +## 1. 文档信息 + +- 产品名称:Showen Flutter App +- 版本:v0.1 +- 阶段:Phase 2 立项准备 +- 负责人:刘建国(PM) +- 目标平台:iOS / Android +- 技术栈:Flutter 3.x + Dart 3.x + +## 2. 产品背景 + +ShowenV2 当前已具备 HTTP API、WebSocket 推送与 BLE GATT 配网能力,能够支撑移动端从首次接入到日常远程控制的完整链路。现阶段需要为 Flutter 客户端形成统一产品定义与开发拆解,降低多端接入成本,并为 iOS/Android 双平台提供同一套体验与交付节奏。 + +Flutter App 定位为 Showen 设备的移动控制器,重点解决三类问题: + +1. 首次接入困难:用户不知道设备 IP,必须通过 BLE 近场配网完成入网。 +2. 日常控制分散:播放控制、状态机触发、场景切换、WiFi/视频/配置管理需要统一入口。 +3. 状态反馈滞后:必须通过 WebSocket 获得设备状态、状态机、WiFi 状态的实时变化。 + +## 3. 产品目标 + +### 3.1 业务目标 + +- 建立移动端首次接入闭环:BLE 发现 -> WiFi 配网 -> 获取设备 IP -> HTTP/WebSocket 连接。 +- 建立移动端高频控制闭环:首页快捷控制 + 播放控制 + 状态机触发。 +- 建立移动端设备管理闭环:设备发现、连接、切换、网络设置、配置与视频管理。 + +### 3.2 用户目标 + +- 3 分钟内完成新设备首次配网。 +- 1 次点击完成播放/暂停等高频控制。 +- 2 秒内看到设备状态变化反馈。 + +### 3.3 成功指标 + +- 首次配网成功率 >= 85%。 +- 核心控制接口成功率 >= 99%(局域网正常环境)。 +- WebSocket 断线自动重连成功率 >= 95%。 +- 首页高频操作平均响应感知时间 <= 800ms。 + +## 4. 用户与场景 + +### 4.1 目标用户 + +- 设备主人:日常使用 Showen 设备的普通用户。 +- 调试人员:需要快速完成设备联网、视频切换、配置更新的部署人员。 +- 高级用户:需要手动输入 IP、管理视频和配置的深度用户。 + +### 4.2 核心使用场景 + +1. 新设备首次开机,用户通过 BLE 扫描到名为 `Showen` 的设备并完成 WiFi 配网。 +2. 用户在局域网内通过首页直接查看设备状态并做播放控制。 +3. 用户进入状态机页触发 `trigger` 或切换 `scene`,立即看到状态变化。 +4. 用户进入网络设置页查看 WiFi 状态、扫描网络、切换网络,必要时使用 BLE 近场控制。 +5. 用户进入设置页查看/更新配置,管理视频资源。 + +## 5. 产品范围 + +### 5.1 本期范围(MVP) + +- BLE 蓝牙配网 +- 设备发现(BLE + 手动输入 IP) +- HTTP 远程控制 +- WebSocket 实时状态订阅 +- 首页、播放控制页、状态机页、网络设置页、设置页 +- WiFi 管理、视频管理、配置管理 +- 多设备本地记录与快速切换 + +### 5.2 暂不纳入本期 + +- 用户账号体系与云端同步 +- 设备远程广域网访问 +- 推送通知 +- 视频预览/在线播放 +- 固件升级 OTA +- 平板/桌面专属布局优化 + +## 6. 依赖与约束 + +### 6.1 服务端依赖 + +- HTTP Base URL:`http://:8080/api` +- WebSocket:`ws://:8080/ws` +- 当前局域网环境默认无认证 +- 依赖服务端已支持:播放、场景、触发器、配置、视频、WiFi、BLE 状态相关接口 + +### 6.2 BLE GATT 协议 + +- Service UUID:`12345678-1234-5678-1234-56789abcdef0` +- SSID 特征:`12345678-1234-5678-1234-56789abcdef1`(write) +- Password 特征:`12345678-1234-5678-1234-56789abcdef2`(write) +- Command 特征:`12345678-1234-5678-1234-56789abcdef3`(write) +- Status 特征:`12345678-1234-5678-1234-56789abcdef4`(read/notify) + +Command 写入格式: + +- 配网:`connect:ssid:password` +- 控制:`play` / `pause` / `next` / `prev` + +Status JSON: + +```json +{ + "ok": true, + "action": "connect", + "state": "connected", + "error": null +} +``` + +## 7. 信息架构 + +底部导航建议 5 个 Tab: + +1. 首页 +2. 播放控制 +3. 状态机 +4. 网络设置 +5. 设置 + +全局能力: + +- 顶部当前设备切换入口 +- 全局连接状态提示 +- 全局 Toast / 错误弹层 +- 全局 WebSocket 重连状态条 + +## 8. 功能需求 + +### 8.1 设备发现与连接 + +#### 目标 + +让用户能够发现附近设备、手动接入已有设备,并在 App 内维护最近连接设备列表。 + +#### 功能点 + +- BLE 扫描发现广播名为 `Showen` 的设备。 +- 支持显示扫描中、扫描失败、未发现设备状态。 +- 支持手动输入设备 IP 直接连接。 +- 支持保存最近使用设备(名称、IP、上次连接时间、连接方式)。 +- 支持切换当前控制设备。 + +#### 验收标准 + +- BLE 扫描 10 秒内可展示发现结果。 +- 手动输入 IP 成功后可自动拉取 `/api/status` 验证设备可达。 +- 最近设备列表支持至少 10 条本地缓存。 + +### 8.2 BLE 蓝牙配网 + +#### 目标 + +在设备未联网或未知 IP 时,让用户通过手机蓝牙完成 WiFi 入网。 + +#### 功能点 + +- 扫描并连接 BLE 设备。 +- 展示手机权限状态(蓝牙、定位)。 +- 输入或选择 WiFi SSID、输入密码。 +- 按协议写入 SSID/Password/Command 特征值。 +- 监听 Status 特征值读取或 notify,显示配网中/成功/失败。 +- 配网成功后提示用户切换到 WiFi 控制模式,并记录返回状态。 + +#### 交互流程 + +1. 用户进入网络设置页 -> 点击 BLE 配网。 +2. App 扫描附近 `Showen` 设备并展示列表。 +3. 用户选择设备,输入 WiFi SSID/密码。 +4. App 写入 GATT 特征并发送 `connect:ssid:password`。 +5. App 监听 `Status` 返回: + - 成功:提示联网成功,并引导发现/填写设备 IP。 + - 失败:显示错误原因并支持重试。 + +#### 验收标准 + +- BLE 配网流程在弱网/失败情况下有明确错误提示。 +- 配网状态页能展示至少 4 类状态:连接中、发送中、成功、失败。 +- 重试不需要重新进入页面。 + +### 8.3 首页 + +#### 目标 + +作为高频控制入口,快速查看设备状态并执行最常用操作。 + +#### 功能点 + +- 展示设备在线状态、当前 IP、WiFi 状态、WebSocket 状态。 +- 展示播放状态:运行中、暂停中、当前视频、当前索引。 +- 提供快捷按钮:播放、暂停、上一首、下一首。 +- 展示最近一次状态机状态。 +- 展示关键告警:未连接、WiFi 异常、WebSocket 断开。 + +#### 相关接口 + +- `GET /api/status` +- `GET /api/wifi/status` +- WebSocket:`status_update`、`state_update`、`wifi_update` + +### 8.4 播放控制页 + +#### 目标 + +提供完整播放控制和播放列表跳转能力。 + +#### 功能点 + +- 大按钮控制:播放、暂停、上一个、下一个。 +- 播放状态信息:当前视频、总数、当前索引。 +- 播放列表展示与点击跳转。 +- 支持 `goto(index)` 精准切换。 +- 支持下拉刷新播放状态。 + +#### 相关接口 + +- `POST /api/play` +- `POST /api/pause` +- `POST /api/next` +- `POST /api/previous` +- `POST /api/goto/:index` +- `GET /api/playlist` +- `GET /api/status` + +### 8.5 状态机页 + +#### 目标 + +让用户能够感知当前状态并显式触发状态机行为。 + +#### 功能点 + +- 展示当前状态、最近状态变化时间。 +- 展示可用 `trigger` 列表,支持无值与带值触发。 +- 展示常用 `scene` 列表,支持快速切换。 +- 显示触发结果反馈和状态更新记录。 + +#### 相关接口 + +- `POST /api/trigger/:name` +- `POST /api/trigger/:name/:value` +- `POST /api/scene/:name` +- WebSocket:`state_update` + +### 8.6 网络设置页 + +#### 目标 + +统一管理 BLE 配网与 WiFi 能力,降低设备联网操作门槛。 + +#### 功能点 + +- BLE 配网入口。 +- 显示当前 WiFi 连接状态、SSID、IP。 +- 扫描可用 WiFi 列表。 +- 选择 WiFi 并通过 HTTP 发起连接。 +- 启动/关闭热点。 +- 显示 BLE 运行状态。 +- 提供 BLE 简单控制命令入口(如 play/pause/next/prev)用于近场调试。 + +#### 相关接口 + +- `GET /api/wifi/status` +- `GET /api/wifi/scan` +- `POST /api/wifi/connect` +- `POST /api/wifi/ap/start` +- `POST /api/wifi/ap/stop` +- `GET /api/ble/status` +- WebSocket:`wifi_update` + +### 8.7 设置页 + +#### 目标 + +承载低频但必要的管理能力。 + +#### 功能点 + +- 查看完整配置。 +- 编辑并提交配置。 +- 查看视频列表。 +- 上传视频(若移动端能力与后端联调稳定则纳入;否则本期先只读 + 删除)。 +- 删除视频。 +- 展示设备信息、App 版本、接口地址。 +- 关于页与排障说明。 + +#### 相关接口 + +- `GET /api/config` +- `POST /api/config` +- `GET /api/videos` +- `POST /api/videos/upload` +- `DELETE /api/videos/:filename` + +## 9. 实时状态设计 + +### 9.1 WebSocket 事件 + +- `status_update`:播放状态更新 +- `state_update`:状态机状态更新 +- `wifi_update`:WiFi 状态更新 + +### 9.2 客户端处理要求 + +- 建立单例 WebSocket 连接。 +- 支持自动重连(退避策略)。 +- 断线期间显示弱提示,不阻塞已缓存页面操作。 +- 收到推送后更新首页、播放控制页、状态机页、网络设置页的对应状态。 + +## 10. 非功能需求 + +### 10.1 性能 + +- 首页首屏可交互时间 <= 2s(已连接设备场景)。 +- 单次控制请求超时默认 5s。 +- BLE 扫描页进入后 1s 内显示扫描中状态。 + +### 10.2 可用性 + +- 所有关键操作必须有加载态、成功态、失败态。 +- 断网、设备离线、接口超时必须有用户可理解提示。 +- 关键页面支持下拉刷新或重试。 + +### 10.3 兼容性 + +- Android 10+ +- iOS 15+ +- 适配主流手机尺寸 + +### 10.4 安全与隐私 + +- WiFi 密码仅在本地临时使用,不长期明文持久化。 +- 本地仅缓存必要设备信息,不缓存敏感配置副本。 +- 预留后续接入 Token/Auth 的网络层扩展点。 + +## 11. 设计要求 + +基于 `clients/docs/DESIGN.md`,Flutter App 延续以下方向: + +- 默认暗色科技风。 +- 强调状态反馈与动效流畅。 +- 移动优先、触控友好。 +- 大按钮、清晰卡片分组、底部导航稳定。 + +同时补充移动端实现约束: + +- 首页、播放控制页优先单手操作。 +- 关键按钮点击区域 >= 48x48dp。 +- 状态颜色必须与文案双重表达,避免只靠颜色区分。 + +## 12. 埋点与日志 + +MVP 至少记录以下本地日志事件,用于调试和测试: + +- BLE 扫描开始/结束/失败 +- BLE 连接成功/失败 +- 配网命令发送结果 +- HTTP 控制请求成功/失败 +- WebSocket 连接/断开/重连 +- 设备切换 + +## 13. 风险与应对 + +### 13.1 技术风险 + +1. BLE notify 行为可能依赖服务端实现完整性。 + - 应对:客户端同时支持 read + notify 两种状态获取模式。 +2. WebSocket 推送事件命名与文档存在潜在偏差。 + - 应对:建立兼容解析层,并在联调阶段冻结事件模型。 +3. `/api/playlist`、配置热重载等能力可能与服务端行为存在细微差异。 + - 应对:MVP 先以“展示 + 控制成功反馈”为主,复杂行为以联调结果修正文档。 + +### 13.2 产品风险 + +1. 首次配网流程过长导致流失。 + - 应对:提供分步引导、权限解释、失败重试。 +2. 设备 IP 获取链路不清晰。 + - 应对:BLE 配网成功后明确提示“请在路由器管理页或设备屏幕查看 IP”,并支持手动输入。 + +## 14. 验收标准 + +### 14.1 功能验收 + +- 能发现并连接 BLE `Showen` 设备。 +- 能完成 WiFi SSID/密码写入与 `connect` 命令发送。 +- 能通过 HTTP 完成播放控制、状态机触发、场景切换、WiFi 管理、视频管理、配置管理。 +- 能通过 BLE 扫描与手动 IP 两种方式建立设备连接。 +- 能通过 WebSocket 实时接收并刷新 `status_update` / `state_update` / `wifi_update`。 + +### 14.2 体验验收 + +- 五个核心页面链路完整,无死路由。 +- 核心控制操作均有反馈。 +- 网络异常和权限异常均有可理解提示。 + +## 15. 里程碑建议 + +- M1:项目脚手架 + 基础架构 + 设备发现 +- M2:BLE 配网 + HTTP 控制闭环 +- M3:WebSocket 实时状态 + 五大页面完成 +- M4:视频/配置管理 + 稳定性优化 + 测试发布 + +## 16. 开发协作说明 + +- 产品文档:`clients/flutter/PRD.md` +- 开发看板:`clients/flutter/TASKS.md` +- 团队沟通记录:`.showen/TEAM_CHAT.md` + +本 PRD 用于 Flutter App 立项、设计、研发与测试的统一输入,后续联调如发现 API 差异,以服务端实际行为校正文档并增量更新版本记录。 diff --git a/clients/flutter/TASKS.md b/clients/flutter/TASKS.md new file mode 100644 index 0000000..78f2724 --- /dev/null +++ b/clients/flutter/TASKS.md @@ -0,0 +1,164 @@ +# Showen Flutter App 开发任务看板 + +## 说明 + +- 估时单位:人时(h)/ 人日(d) +- 优先级:P0 = MVP 必须完成,P1 = 首发建议完成,P2 = 可后续迭代 +- 状态:当前统一标记为 `待开始` + +## 总体节奏 + +| 阶段 | 目标 | 预计工时 | +|------|------|----------| +| Sprint 1 | 脚手架、架构、设备发现、基础网络层 | 4-5d | +| Sprint 2 | BLE 配网、HTTP 控制、首页/播放控制页 | 5-6d | +| Sprint 3 | 状态机页、网络设置页、WebSocket 实时状态 | 4-5d | +| Sprint 4 | 设置页、视频/配置管理、测试与发布准备 | 4-5d | + +预计总工时:17-21 人日 + +## 模块一:项目基础架构 + +| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 | +|----|------|--------|----------|------|------| +| A1 | 创建 Flutter 工程基础目录、环境区分、包管理方案 | P0 | 4h | 无 | 待开始 | +| A2 | 搭建路由、主题、全局错误处理、全局 Toast 能力 | P0 | 6h | A1 | 待开始 | +| A3 | 建立分层架构(presentation/application/domain/data) | P0 | 6h | A1 | 待开始 | +| A4 | 建立统一 API Client、错误模型、超时/重试策略 | P0 | 6h | A3 | 待开始 | +| A5 | 建立本地存储方案(设备历史、设置项) | P0 | 4h | A3 | 待开始 | +| A6 | 建立日志与调试开关 | P1 | 3h | A3 | 待开始 | + +模块小计:29h + +## 模块二:设备发现与连接管理 + +| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 | +|----|------|--------|----------|------|------| +| D1 | 设计设备实体与连接状态模型 | P0 | 3h | A3 | 待开始 | +| D2 | 实现手动输入 IP 连接与可达性校验 | P0 | 4h | A4 | 待开始 | +| D3 | 实现最近设备列表的本地存储、切换、删除 | P0 | 5h | A5,D1 | 待开始 | +| D4 | 实现全局当前设备上下文与切换机制 | P0 | 5h | D1,D3 | 待开始 | +| D5 | 设备连接失败、离线、超时提示与兜底页 | P1 | 4h | D2,D4 | 待开始 | + +模块小计:21h + +## 模块三:BLE 蓝牙配网 + +| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 | +|----|------|--------|----------|------|------| +| B1 | 选型并接入 Flutter BLE 插件,完成 iOS/Android 权限配置 | P0 | 6h | A1 | 待开始 | +| B2 | 实现扫描 `Showen` 设备列表与连接流程 | P0 | 8h | B1 | 待开始 | +| B3 | 实现 GATT Service/Characteristic 封装 | P0 | 6h | B1 | 待开始 | +| B4 | 实现 SSID/Password/Command 写入与配网命令发送 | P0 | 6h | B3 | 待开始 | +| B5 | 实现 Status 特征 read/notify 双模式监听与 JSON 解析 | P0 | 8h | B3 | 待开始 | +| B6 | 实现 BLE 配网页 UI、失败重试、权限引导 | P0 | 8h | B2,B4,B5 | 待开始 | +| B7 | 实现 BLE 简单控制命令(play/pause/next/prev) | P1 | 4h | B3 | 待开始 | +| B8 | BLE 异常专项测试(权限、断连、超时、重复配网) | P1 | 6h | B6 | 待开始 | + +模块小计:52h + +## 模块四:HTTP API 控制层 + +| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 | +|----|------|--------|----------|------|------| +| H1 | 封装播放控制接口(play/pause/next/previous/goto) | P0 | 4h | A4 | 待开始 | +| H2 | 封装状态机接口(trigger/scene) | P0 | 4h | A4 | 待开始 | +| H3 | 封装 WiFi 接口(status/scan/connect/ap start/stop) | P0 | 5h | A4 | 待开始 | +| H4 | 封装配置接口(get config/post config) | P0 | 4h | A4 | 待开始 | +| H5 | 封装视频接口(list/upload/delete) | P1 | 6h | A4 | 待开始 | +| H6 | 封装 BLE 状态接口(get ble status) | P1 | 2h | A4 | 待开始 | +| H7 | 建立统一响应校验、错误码映射与空响应处理 | P0 | 4h | H1,H2,H3,H4 | 待开始 | + +模块小计:29h + +## 模块五:WebSocket 实时状态 + +| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 | +|----|------|--------|----------|------|------| +| W1 | 建立 WebSocket 连接管理器与事件分发器 | P0 | 6h | A3 | 待开始 | +| W2 | 解析 `status_update` / `state_update` / `wifi_update` 事件 | P0 | 5h | W1 | 待开始 | +| W3 | 实现自动重连、退避、前后台切换恢复 | P0 | 6h | W1 | 待开始 | +| W4 | 将实时状态同步到首页、播放页、状态机页、网络页 | P0 | 5h | W2 | 待开始 | +| W5 | 实现连接状态提示条与调试日志面板 | P1 | 4h | W3 | 待开始 | + +模块小计:26h + +## 模块六:页面开发 + +| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 | +|----|------|--------|----------|------|------| +| UI1 | 实现首页:设备状态、快捷控制、当前播放信息 | P0 | 8h | D4,H1,W4 | 待开始 | +| UI2 | 实现播放控制页:大按钮、播放列表、goto 操作 | P0 | 8h | H1,W4 | 待开始 | +| UI3 | 实现状态机页:当前状态、trigger、scene | P0 | 8h | H2,W4 | 待开始 | +| UI4 | 实现网络设置页:BLE 配网、WiFi 管理、BLE 控制 | P0 | 10h | B6,H3,H6,W4 | 待开始 | +| UI5 | 实现设置页:配置管理、视频管理、关于 | P0 | 10h | H4,H5 | 待开始 | +| UI6 | 实现底部导航、设备切换入口、通用空态/异常态 | P0 | 6h | A2,D4 | 待开始 | + +模块小计:50h + +## 模块七:配置与视频管理增强 + +| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 | +|----|------|--------|----------|------|------| +| C1 | 配置查看器:JSON 格式化、复制、刷新 | P1 | 4h | H4 | 待开始 | +| C2 | 配置编辑器:表单/文本双模式评估并实现 MVP | P1 | 8h | C1 | 待开始 | +| C3 | 视频列表管理:删除确认、刷新状态回显 | P1 | 4h | H5 | 待开始 | +| C4 | 视频上传(选文件、进度、失败处理) | P2 | 8h | H5 | 待开始 | + +模块小计:24h + +## 模块八:质量保障与发布准备 + +| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 | +|----|------|--------|----------|------|------| +| Q1 | 编写核心单元测试(模型、解析、状态管理) | P0 | 8h | A3,H1,H2,H3,W2 | 待开始 | +| Q2 | 编写 Widget 测试(首页、播放页、网络页关键交互) | P1 | 8h | UI1,UI2,UI4 | 待开始 | +| Q3 | 真机联调清单(Android/iOS、BLE、HTTP、WebSocket) | P0 | 6h | B6,UI1,UI2,UI3,UI4,UI5 | 待开始 | +| Q4 | 发布前缺陷收敛、性能优化、崩溃排查 | P0 | 8h | Q1,Q2,Q3 | 待开始 | +| Q5 | 产出接入说明、测试报告、已知问题列表 | P1 | 4h | Q3,Q4 | 待开始 | + +模块小计:34h + +## 并行建议 + +### 可并行推进 + +1. 基础架构(A1-A5)完成后,可并行推进 D 模块、B1-B3、H 模块、W1。 +2. HTTP 封装和 WebSocket 管理器完成后,UI1-UI5 可拆给不同开发者并行开发。 +3. BLE 配网页(B6)与网络设置页(UI4)可先做骨架,再在 BLE 联调完成后收口。 + +### 串行关键链路 + +1. `A1 -> A3 -> A4` 是全局基础链路,必须优先完成。 +2. `B1 -> B3 -> B4/B5 -> B6` 是 BLE 配网主链路,不能跳步。 +3. `W1 -> W2 -> W4` 是实时状态闭环核心链路。 + +## 关键里程碑验收 + +### 里程碑 M1:基础可运行 + +- 工程可运行 +- 手动输入 IP 可连接设备 +- 基础 HTTP 请求可成功 + +### 里程碑 M2:首次接入闭环 + +- BLE 扫描、连接、配网可用 +- 网络设置页完成基本闭环 + +### 里程碑 M3:控制闭环 + +- 首页、播放页、状态机页可完成核心控制 +- WebSocket 状态实时更新可用 + +### 里程碑 M4:首发就绪 + +- 设置页、视频/配置管理完成 +- 真机联调完成 +- 已知问题可控并形成发布清单 + +## 风险备注 + +- BLE 插件在 iOS/Android 权限与后台行为差异大,需预留专项联调时间。 +- `/api/videos/upload`、配置热重载等能力依赖服务端联调结果,建议先保证查看/删除/提交基础能力。 +- 若服务端 WebSocket 事件字段与文档不一致,优先修正客户端解析兼容层,不阻塞主流程。 diff --git a/clients/flutter/android/app/build.gradle b/clients/flutter/android/app/build.gradle new file mode 100644 index 0000000..abd38b1 --- /dev/null +++ b/clients/flutter/android/app/build.gradle @@ -0,0 +1,37 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' + id 'dev.flutter.flutter-gradle-plugin' +} + +android { + namespace 'com.showen.flutter' + compileSdk flutter.compileSdkVersion + + defaultConfig { + applicationId 'com.showen.flutter' + minSdkVersion 21 + targetSdkVersion flutter.targetSdkVersion + versionCode flutter.versionCode + versionName flutter.versionName + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + buildTypes { + release { + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} diff --git a/clients/flutter/android/app/src/main/AndroidManifest.xml b/clients/flutter/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d1323a2 --- /dev/null +++ b/clients/flutter/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/clients/flutter/android/app/src/main/kotlin/com/showen/flutter/MainActivity.kt b/clients/flutter/android/app/src/main/kotlin/com/showen/flutter/MainActivity.kt new file mode 100644 index 0000000..b597f34 --- /dev/null +++ b/clients/flutter/android/app/src/main/kotlin/com/showen/flutter/MainActivity.kt @@ -0,0 +1,5 @@ +package com.showen.flutter + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/clients/flutter/android/app/src/main/res/values/styles.xml b/clients/flutter/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..4ecf9b2 --- /dev/null +++ b/clients/flutter/android/app/src/main/res/values/styles.xml @@ -0,0 +1,6 @@ + + + + diff --git a/clients/flutter/android/build.gradle b/clients/flutter/android/build.gradle new file mode 100644 index 0000000..e65f48a --- /dev/null +++ b/clients/flutter/android/build.gradle @@ -0,0 +1,15 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} + +tasks.register('clean', Delete) { + delete rootProject.buildDir +} diff --git a/clients/flutter/android/gradle.properties b/clients/flutter/android/gradle.properties new file mode 100644 index 0000000..452bb42 --- /dev/null +++ b/clients/flutter/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/clients/flutter/android/settings.gradle b/clients/flutter/android/settings.gradle new file mode 100644 index 0000000..1022823 --- /dev/null +++ b/clients/flutter/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file('local.properties').withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty('flutter.sdk') + assert flutterSdkPath != null, 'flutter.sdk not set in local.properties' + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id 'dev.flutter.flutter-plugin-loader' version '1.0.0' + id 'com.android.application' version '8.3.2' apply false + id 'org.jetbrains.kotlin.android' version '1.9.22' apply false +} + +include ':app' diff --git a/clients/flutter/lib/main.dart b/clients/flutter/lib/main.dart new file mode 100644 index 0000000..efe82d0 --- /dev/null +++ b/clients/flutter/lib/main.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import 'providers/device_provider.dart'; +import 'providers/player_provider.dart'; +import 'providers/wifi_provider.dart'; +import 'screens/app_shell.dart'; +import 'screens/ble_provision_screen.dart'; +import 'screens/home_screen.dart'; +import 'screens/network_screen.dart'; +import 'screens/playback_screen.dart'; +import 'screens/settings_screen.dart'; +import 'screens/trigger_screen.dart'; +import 'services/http_api_service.dart'; +import 'services/web_socket_service.dart'; +import 'theme/app_theme.dart'; + +void main() { + final httpApiService = HttpApiService(baseUrl: 'http://127.0.0.1:8080'); + final webSocketService = WebSocketService(); + + runApp( + MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => DeviceProvider( + httpApiService: httpApiService, + webSocketService: webSocketService, + )..initialize(), + ), + ChangeNotifierProvider( + create: (_) => PlayerProvider( + httpApiService: httpApiService, + webSocketService: webSocketService, + ) + ..bootstrap(), + ), + ChangeNotifierProvider( + create: (_) => WifiProvider( + httpApiService: httpApiService, + webSocketService: webSocketService, + ) + ..bootstrap(), + ), + ], + child: const ShowenApp(), + ), + ); +} + +final GoRouter _router = GoRouter( + initialLocation: '/', + routes: [ + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) => + AppShell(navigationShell: navigationShell), + branches: [ + StatefulShellBranch( + routes: [ + GoRoute( + path: '/', + name: 'home', + builder: (context, state) => const HomeScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/playback', + name: 'playback', + builder: (context, state) => const PlaybackScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/trigger', + name: 'trigger', + builder: (context, state) => const TriggerScreen(), + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/network', + name: 'network', + builder: (context, state) => const NetworkScreen(), + routes: [ + GoRoute( + path: 'ble-provision', + name: 'ble-provision', + builder: (context, state) => const BleProvisionScreen(), + ), + ], + ), + ], + ), + StatefulShellBranch( + routes: [ + GoRoute( + path: '/settings', + name: 'settings', + builder: (context, state) => const SettingsScreen(), + ), + ], + ), + ], + ), + ], +); + +class ShowenApp extends StatelessWidget { + const ShowenApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + title: 'ShowenV2', + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.dark, + darkTheme: AppTheme.dark(), + theme: AppTheme.dark(), + routerConfig: _router, + ); + } +} diff --git a/clients/flutter/lib/models/api_response.dart b/clients/flutter/lib/models/api_response.dart new file mode 100644 index 0000000..96bda4e --- /dev/null +++ b/clients/flutter/lib/models/api_response.dart @@ -0,0 +1,22 @@ +class ApiResponse { + const ApiResponse({required this.status, required this.message}); + + final String status; + final String message; + + bool get isOk => status == 'ok'; + + factory ApiResponse.fromJson(Map json) { + return ApiResponse( + status: json['status'] as String? ?? 'error', + message: json['message'] as String? ?? '', + ); + } + + Map toJson() { + return { + 'status': status, + 'message': message, + }; + } +} diff --git a/clients/flutter/lib/models/app_event.dart b/clients/flutter/lib/models/app_event.dart new file mode 100644 index 0000000..0a6674a --- /dev/null +++ b/clients/flutter/lib/models/app_event.dart @@ -0,0 +1,24 @@ +class AppEvent { + const AppEvent({required this.type, required this.payload}); + + final String type; + final Map payload; + + factory AppEvent.fromJson(Map json) { + final dynamic rawPayload = json['data'] ?? json['payload'] ?? const {}; + return AppEvent( + type: json['type'] as String? ?? 'unknown', + payload: _normalizePayload(rawPayload), + ); + } + + static Map _normalizePayload(dynamic payload) { + if (payload is Map) { + return payload; + } + if (payload is Map) { + return Map.from(payload); + } + return {'value': payload}; + } +} diff --git a/clients/flutter/lib/models/ble_models.dart b/clients/flutter/lib/models/ble_models.dart new file mode 100644 index 0000000..557cb70 --- /dev/null +++ b/clients/flutter/lib/models/ble_models.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; + +class BleDevice { + const BleDevice({ + required this.name, + required this.id, + required this.rssi, + }); + + final String name; + final String id; + final int rssi; +} + +class BleStatus { + const BleStatus({ + required this.ok, + required this.action, + this.state, + this.error, + }); + + factory BleStatus.fromJson(Map json) { + return BleStatus( + ok: json['ok'] == true, + action: (json['action'] ?? '').toString(), + state: json['state']?.toString(), + error: json['error']?.toString(), + ); + } + + factory BleStatus.fromRawJson(String source) { + final dynamic decoded = jsonDecode(source); + if (decoded is! Map) { + throw const FormatException('BLE status payload is not a JSON object'); + } + return BleStatus.fromJson(decoded); + } + + static const BleStatus idle = BleStatus(ok: true, action: 'idle'); + + final bool ok; + final String action; + final String? state; + final String? error; + + bool get isQueued => state == 'queued'; + + bool get isSuccess => ok && !isQueued; + + String get message { + if ((error ?? '').isNotEmpty) { + return error!; + } + if ((state ?? '').isNotEmpty) { + return state!; + } + return action; + } +} + +enum ProvisioningState { + scanning, + connecting, + writingCredentials, + connectingWifi, + success, + failed, +} diff --git a/clients/flutter/lib/models/ble_status.dart b/clients/flutter/lib/models/ble_status.dart new file mode 100644 index 0000000..0adc49f --- /dev/null +++ b/clients/flutter/lib/models/ble_status.dart @@ -0,0 +1,23 @@ +class BleServiceStatus { + const BleServiceStatus({ + required this.running, + required this.embedded, + this.deviceName, + }); + + final bool running; + final bool embedded; + final String? deviceName; + + factory BleServiceStatus.initial() { + return const BleServiceStatus(running: false, embedded: false); + } + + factory BleServiceStatus.fromJson(Map json) { + return BleServiceStatus( + running: json['running'] as bool? ?? false, + embedded: json['embedded'] as bool? ?? false, + deviceName: json['device_name'] as String?, + ); + } +} diff --git a/clients/flutter/lib/models/device_status.dart b/clients/flutter/lib/models/device_status.dart new file mode 100644 index 0000000..1776f80 --- /dev/null +++ b/clients/flutter/lib/models/device_status.dart @@ -0,0 +1,54 @@ +import 'ble_status.dart'; +import 'player_status.dart'; +import 'wifi_status.dart'; + +class DeviceStatus { + const DeviceStatus({ + required this.connected, + required this.connectionType, + this.deviceName, + this.ipAddress, + this.playerStatus, + this.wifiStatus, + this.bleStatus, + this.updatedAt, + }); + + final bool connected; + final String connectionType; + final String? deviceName; + final String? ipAddress; + final PlayerStatus? playerStatus; + final WifiStatus? wifiStatus; + final BleServiceStatus? bleStatus; + final DateTime? updatedAt; + + factory DeviceStatus.initial() { + return const DeviceStatus( + connected: false, + connectionType: 'offline', + ); + } + + DeviceStatus copyWith({ + bool? connected, + String? connectionType, + String? deviceName, + String? ipAddress, + PlayerStatus? playerStatus, + WifiStatus? wifiStatus, + BleServiceStatus? bleStatus, + DateTime? updatedAt, + }) { + return DeviceStatus( + connected: connected ?? this.connected, + connectionType: connectionType ?? this.connectionType, + deviceName: deviceName ?? this.deviceName, + ipAddress: ipAddress ?? this.ipAddress, + playerStatus: playerStatus ?? this.playerStatus, + wifiStatus: wifiStatus ?? this.wifiStatus, + bleStatus: bleStatus ?? this.bleStatus, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} diff --git a/clients/flutter/lib/models/player_status.dart b/clients/flutter/lib/models/player_status.dart new file mode 100644 index 0000000..4814fb0 --- /dev/null +++ b/clients/flutter/lib/models/player_status.dart @@ -0,0 +1,68 @@ +class PlayerStatus { + const PlayerStatus({ + required this.running, + required this.paused, + required this.inTransition, + required this.currentIndex, + required this.playlistLength, + this.currentVideo, + }); + + final bool running; + final bool paused; + final bool inTransition; + final int currentIndex; + final int playlistLength; + final String? currentVideo; + + factory PlayerStatus.initial() { + return const PlayerStatus( + running: false, + paused: false, + inTransition: false, + currentIndex: 0, + playlistLength: 0, + currentVideo: null, + ); + } + + factory PlayerStatus.fromJson(Map json) { + return PlayerStatus( + running: json['running'] as bool? ?? false, + paused: json['paused'] as bool? ?? false, + inTransition: json['in_transition'] as bool? ?? false, + currentIndex: json['current_index'] as int? ?? 0, + playlistLength: json['playlist_length'] as int? ?? 0, + currentVideo: json['current_video'] as String?, + ); + } + + Map toJson() { + return { + 'running': running, + 'paused': paused, + 'in_transition': inTransition, + 'current_index': currentIndex, + 'playlist_length': playlistLength, + 'current_video': currentVideo, + }; + } + + PlayerStatus copyWith({ + bool? running, + bool? paused, + bool? inTransition, + int? currentIndex, + int? playlistLength, + String? currentVideo, + }) { + return PlayerStatus( + running: running ?? this.running, + paused: paused ?? this.paused, + inTransition: inTransition ?? this.inTransition, + currentIndex: currentIndex ?? this.currentIndex, + playlistLength: playlistLength ?? this.playlistLength, + currentVideo: currentVideo ?? this.currentVideo, + ); + } +} diff --git a/clients/flutter/lib/models/video_item.dart b/clients/flutter/lib/models/video_item.dart new file mode 100644 index 0000000..9dc83c5 --- /dev/null +++ b/clients/flutter/lib/models/video_item.dart @@ -0,0 +1,23 @@ +class VideoItem { + const VideoItem({required this.name, required this.size}); + + final String name; + final int size; + + factory VideoItem.fromJson(Map json) { + return VideoItem( + name: json['name'] as String? ?? '', + size: json['size'] as int? ?? 0, + ); + } + + String get sizeLabel { + if (size >= 1024 * 1024) { + return '${(size / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + if (size >= 1024) { + return '${(size / 1024).toStringAsFixed(1)} KB'; + } + return '$size B'; + } +} diff --git a/clients/flutter/lib/models/wifi_network.dart b/clients/flutter/lib/models/wifi_network.dart new file mode 100644 index 0000000..4fae325 --- /dev/null +++ b/clients/flutter/lib/models/wifi_network.dart @@ -0,0 +1,21 @@ +class WifiNetwork { + const WifiNetwork({ + required this.ssid, + required this.signal, + required this.security, + }); + + final String ssid; + final int signal; + final String security; + + factory WifiNetwork.fromJson(Map json) { + return WifiNetwork( + ssid: json['ssid'] as String? ?? '', + signal: json['signal'] as int? ?? 0, + security: json['security'] as String? ?? 'Unknown', + ); + } + + String get signalLabel => '$signal dBm'; +} diff --git a/clients/flutter/lib/models/wifi_status.dart b/clients/flutter/lib/models/wifi_status.dart new file mode 100644 index 0000000..26ddc8a --- /dev/null +++ b/clients/flutter/lib/models/wifi_status.dart @@ -0,0 +1,23 @@ +class WifiStatus { + const WifiStatus({ + required this.connected, + this.ssid, + this.ip, + }); + + final bool connected; + final String? ssid; + final String? ip; + + factory WifiStatus.disconnected() { + return const WifiStatus(connected: false); + } + + factory WifiStatus.fromJson(Map json) { + return WifiStatus( + connected: json['connected'] as bool? ?? false, + ssid: json['ssid'] as String?, + ip: json['ip'] as String?, + ); + } +} diff --git a/clients/flutter/lib/providers/ble_provider.dart b/clients/flutter/lib/providers/ble_provider.dart new file mode 100644 index 0000000..78b89b9 --- /dev/null +++ b/clients/flutter/lib/providers/ble_provider.dart @@ -0,0 +1,196 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../models/ble_models.dart'; +import '../services/ble_service.dart'; + +class BleProvider extends ChangeNotifier { + BleProvider({BleService? bleService}) : _bleService = bleService ?? BleService(); + + final BleService _bleService; + + StreamSubscription>? _scanSubscription; + StreamSubscription? _statusSubscription; + + List _devices = const []; + BleDevice? _selectedDevice; + BleStatus? _latestStatus; + ProvisioningState _provisioningState = ProvisioningState.scanning; + String? _errorMessage; + bool _isScanning = false; + bool _isConnecting = false; + bool _isProvisioning = false; + bool _isConnected = false; + bool _isDisposed = false; + + List get devices => _devices; + BleDevice? get selectedDevice => _selectedDevice; + BleStatus? get latestStatus => _latestStatus; + ProvisioningState get provisioningState => _provisioningState; + String? get errorMessage => _errorMessage; + bool get isScanning => _isScanning; + bool get isConnecting => _isConnecting; + bool get isProvisioning => _isProvisioning; + bool get isConnected => _isConnected; + + Future startScan() async { + _errorMessage = null; + _selectedDevice = null; + _isConnected = false; + _provisioningState = ProvisioningState.scanning; + _isScanning = true; + _notifySafely(); + + await _scanSubscription?.cancel(); + _scanSubscription = _bleService + .scanForShowenDevices() + .listen((List scannedDevices) { + _devices = scannedDevices; + _notifySafely(); + }, onError: (Object error, StackTrace stackTrace) { + _errorMessage = error.toString(); + _isScanning = false; + _provisioningState = ProvisioningState.failed; + _notifySafely(); + }); + + Future.delayed(const Duration(seconds: 6), () { + if (_isScanning) { + _isScanning = false; + _notifySafely(); + } + }); + } + + Future connectToDevice(BleDevice device) async { + _selectedDevice = device; + _errorMessage = null; + _isConnecting = true; + _isScanning = false; + _provisioningState = ProvisioningState.connecting; + _notifySafely(); + + try { + await _bleService.connectToDevice(device); + await _subscribeToStatus(); + _isConnected = true; + } catch (error) { + _isConnected = false; + _errorMessage = error.toString(); + _provisioningState = ProvisioningState.failed; + rethrow; + } finally { + _isConnecting = false; + _notifySafely(); + } + } + + Future provisionWifi(String ssid, String password) async { + _errorMessage = null; + _latestStatus = null; + _isProvisioning = true; + _provisioningState = ProvisioningState.writingCredentials; + _notifySafely(); + + try { + final Future operation = _bleService.provisionWifi( + ssid, + password, + timeout: const Duration(seconds: 30), + ); + + Future.delayed(const Duration(milliseconds: 400), () { + if (_isProvisioning && + _provisioningState == ProvisioningState.writingCredentials) { + _provisioningState = ProvisioningState.connectingWifi; + _notifySafely(); + } + }); + + final BleStatus result = await operation; + _latestStatus = result; + _provisioningState = result.ok + ? ProvisioningState.success + : ProvisioningState.failed; + if (!result.ok) { + _errorMessage = result.error ?? 'WiFi provisioning failed'; + } + } on TimeoutException { + _errorMessage = 'BLE 配网超时(30 秒)'; + _provisioningState = ProvisioningState.failed; + rethrow; + } catch (error) { + _errorMessage = error.toString(); + _provisioningState = ProvisioningState.failed; + rethrow; + } finally { + _isProvisioning = false; + _notifySafely(); + } + } + + Future disconnect() async { + await _scanSubscription?.cancel(); + await _statusSubscription?.cancel(); + _scanSubscription = null; + _statusSubscription = null; + await _bleService.disconnect(); + _isConnected = false; + _isConnecting = false; + _isProvisioning = false; + _selectedDevice = null; + _notifySafely(); + } + + Future retryScan() async { + await disconnect(); + _devices = const []; + _latestStatus = null; + _errorMessage = null; + _provisioningState = ProvisioningState.scanning; + _notifySafely(); + await startScan(); + } + + Future _subscribeToStatus() async { + await _statusSubscription?.cancel(); + final Stream stream = await _bleService.subscribeToStatus(); + _statusSubscription = stream.listen((BleStatus status) { + _latestStatus = status; + if (!status.ok) { + _errorMessage = status.error ?? 'BLE status returned an error'; + } + if (status.action == 'connect') { + if (!status.ok) { + _provisioningState = ProvisioningState.failed; + } else if (!status.isQueued) { + _provisioningState = ProvisioningState.success; + } else if (_isProvisioning) { + _provisioningState = ProvisioningState.connectingWifi; + } + } + _notifySafely(); + }, onError: (Object error, StackTrace stackTrace) { + _errorMessage = error.toString(); + _provisioningState = ProvisioningState.failed; + _notifySafely(); + }); + } + + @override + void dispose() { + _isDisposed = true; + unawaited(_scanSubscription?.cancel()); + unawaited(_statusSubscription?.cancel()); + unawaited(_bleService.dispose()); + super.dispose(); + } + + void _notifySafely() { + if (_isDisposed) { + return; + } + notifyListeners(); + } +} diff --git a/clients/flutter/lib/providers/device_provider.dart b/clients/flutter/lib/providers/device_provider.dart new file mode 100644 index 0000000..54adb81 --- /dev/null +++ b/clients/flutter/lib/providers/device_provider.dart @@ -0,0 +1,233 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../models/ble_status.dart'; +import '../models/device_status.dart'; +import '../models/player_status.dart'; +import '../models/wifi_status.dart'; +import '../services/http_api_service.dart'; +import '../services/web_socket_service.dart'; + +class DeviceProvider extends ChangeNotifier { + DeviceProvider({ + required HttpApiService httpApiService, + required WebSocketService webSocketService, + String initialDeviceIp = '127.0.0.1', + }) : _httpApiService = httpApiService, + _webSocketService = webSocketService, + _deviceIp = _normalizeDeviceIp(initialDeviceIp) { + _httpApiService.baseUrl = 'http://$_deviceIp:8080'; + _connectionSubscription = _webSocketService.onConnectionChanged.listen( + _handleConnectionChanged, + ); + _statusSubscription = _webSocketService.onStatusUpdate.listen(_handleStatusUpdate); + _wifiSubscription = _webSocketService.onWifiUpdate.listen(_handleWifiUpdate); + _bleSubscription = _webSocketService.onBleUpdate.listen(_handleBleUpdate); + } + + final HttpApiService _httpApiService; + final WebSocketService _webSocketService; + + late final StreamSubscription _connectionSubscription; + late final StreamSubscription> _statusSubscription; + late final StreamSubscription> _wifiSubscription; + late final StreamSubscription> _bleSubscription; + + DeviceStatus _status = DeviceStatus.initial(); + bool _isLoading = false; + String? _errorMessage; + bool _webSocketConnected = false; + String _deviceIp; + + DeviceStatus get status => _status; + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + bool get webSocketConnected => _webSocketConnected; + String get deviceIp => _deviceIp; + HttpApiService get httpApiService => _httpApiService; + + Future initialize() async { + await refresh(); + await connect(); + } + + Future refresh() async { + _setLoading(true); + try { + final results = await Future.wait([ + _httpApiService.getPlaybackStatus(), + _httpApiService.getWifiStatus(), + _httpApiService.getBleStatus(), + ]); + + _status = _buildStatus( + playerStatus: results[0] as PlayerStatus, + wifiStatus: results[1] as WifiStatus, + bleStatus: results[2] as BleServiceStatus, + ); + _errorMessage = null; + } catch (error) { + _errorMessage = error.toString(); + _status = _status.copyWith( + connected: false, + connectionType: 'offline', + ipAddress: _deviceIp, + ); + } finally { + _setLoading(false); + } + } + + Future loadDeviceOverview() => refresh(); + + Future connect() async { + try { + await _webSocketService.connect(_deviceIp); + _webSocketConnected = _webSocketService.isConnected; + _errorMessage = null; + notifyListeners(); + } catch (error) { + _webSocketConnected = false; + _errorMessage = error.toString(); + notifyListeners(); + } + } + + Future updateDeviceIp(String ip) async { + final normalized = _normalizeDeviceIp(ip); + _deviceIp = normalized; + _httpApiService.baseUrl = 'http://$_deviceIp:8080'; + _status = _status.copyWith(ipAddress: normalized, updatedAt: DateTime.now()); + notifyListeners(); + + await _webSocketService.disconnect(); + await initialize(); + } + + Future startBle({String? deviceName}) async { + _setLoading(true); + try { + await _httpApiService.startBle(deviceName); + await refresh(); + } catch (error) { + _errorMessage = error.toString(); + notifyListeners(); + } finally { + _setLoading(false); + } + } + + Future stopBle() async { + _setLoading(true); + try { + await _httpApiService.stopBle(); + await refresh(); + } catch (error) { + _errorMessage = error.toString(); + notifyListeners(); + } finally { + _setLoading(false); + } + } + + void _handleConnectionChanged(SocketConnectionStatus connectionStatus) { + _webSocketConnected = connectionStatus == SocketConnectionStatus.connected; + if (!_webSocketConnected) { + _status = _status.copyWith(connectionType: _status.connectionType); + } + notifyListeners(); + } + + void _handleStatusUpdate(Map payload) { + final playerStatus = PlayerStatus.fromJson(payload); + _status = _buildStatus( + playerStatus: playerStatus, + wifiStatus: _status.wifiStatus ?? WifiStatus.disconnected(), + bleStatus: _status.bleStatus ?? BleServiceStatus.initial(), + ); + notifyListeners(); + } + + void _handleWifiUpdate(Map payload) { + final wifiStatus = WifiStatus.fromJson(payload); + _status = _buildStatus( + playerStatus: _status.playerStatus ?? PlayerStatus.initial(), + wifiStatus: wifiStatus, + bleStatus: _status.bleStatus ?? BleServiceStatus.initial(), + ); + notifyListeners(); + } + + void _handleBleUpdate(Map payload) { + final normalized = { + 'running': payload['running'] ?? payload['ready'] ?? false, + 'embedded': payload['embedded'] ?? false, + 'device_name': payload['device_name'] ?? payload['name'], + }; + final bleStatus = BleServiceStatus.fromJson(normalized); + _status = _buildStatus( + playerStatus: _status.playerStatus ?? PlayerStatus.initial(), + wifiStatus: _status.wifiStatus ?? WifiStatus.disconnected(), + bleStatus: bleStatus, + ); + notifyListeners(); + } + + DeviceStatus _buildStatus({ + required PlayerStatus playerStatus, + required WifiStatus wifiStatus, + required BleServiceStatus bleStatus, + }) { + final connectionType = wifiStatus.connected + ? 'wifi' + : bleStatus.running + ? 'ble' + : 'offline'; + + return DeviceStatus( + connected: wifiStatus.connected || bleStatus.running || _webSocketConnected, + connectionType: connectionType, + deviceName: bleStatus.deviceName ?? 'ShowenV2', + ipAddress: wifiStatus.ip ?? _deviceIp, + playerStatus: playerStatus, + wifiStatus: wifiStatus, + bleStatus: bleStatus, + updatedAt: DateTime.now(), + ); + } + + void _setLoading(bool value) { + _isLoading = value; + notifyListeners(); + } + + static String _normalizeDeviceIp(String input) { + var value = input.trim(); + if (value.startsWith('http://')) { + value = value.substring(7); + } else if (value.startsWith('https://')) { + value = value.substring(8); + } else if (value.startsWith('ws://')) { + value = value.substring(5); + } + final slashIndex = value.indexOf('/'); + if (slashIndex >= 0) { + value = value.substring(0, slashIndex); + } + final colonIndex = value.indexOf(':'); + if (colonIndex >= 0) { + value = value.substring(0, colonIndex); + } + return value.isEmpty ? '127.0.0.1' : value; + } + + @override + void dispose() { + unawaited(_connectionSubscription.cancel()); + unawaited(_statusSubscription.cancel()); + unawaited(_wifiSubscription.cancel()); + unawaited(_bleSubscription.cancel()); + super.dispose(); + } +} diff --git a/clients/flutter/lib/providers/player_provider.dart b/clients/flutter/lib/providers/player_provider.dart new file mode 100644 index 0000000..c298e83 --- /dev/null +++ b/clients/flutter/lib/providers/player_provider.dart @@ -0,0 +1,184 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../models/player_status.dart'; +import '../services/http_api_service.dart'; +import '../services/web_socket_service.dart'; + +class PlayerProvider extends ChangeNotifier { + PlayerProvider({ + required HttpApiService httpApiService, + required WebSocketService webSocketService, + }) : _httpApiService = httpApiService, + _webSocketService = webSocketService { + _statusSubscription = _webSocketService.onStatusUpdate.listen((payload) { + _status = PlayerStatus.fromJson(payload); + notifyListeners(); + }); + _stateSubscription = _webSocketService.onStateUpdate.listen((payload) { + _currentState = payload['new_state']?.toString() ?? _currentState; + notifyListeners(); + }); + _configSubscription = _webSocketService.onConfigUpdate.listen((payload) { + _updateSceneOptions(payload); + notifyListeners(); + }); + } + + final HttpApiService _httpApiService; + final WebSocketService _webSocketService; + late final StreamSubscription> _statusSubscription; + late final StreamSubscription> _stateSubscription; + late final StreamSubscription> _configSubscription; + + PlayerStatus _status = PlayerStatus.initial(); + List _playlist = const []; + List _sceneOptions = const ['idle', 'intro', 'loop']; + bool _isLoading = false; + String? _errorMessage; + String? _currentState; + + PlayerStatus get status => _status; + List get playlist => _playlist; + List get sceneOptions => _sceneOptions; + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + String get currentState => _currentState ?? _status.currentVideo ?? 'idle'; + + Future bootstrap() async { + _setLoading(true); + try { + final results = await Future.wait([ + _httpApiService.getPlaybackStatus(), + _httpApiService.getPlaylist(), + _httpApiService.getConfig(), + ]); + _status = results[0] as PlayerStatus; + _playlist = results[1] as List; + _updateSceneOptions(results[2] as Map); + _errorMessage = null; + } catch (error) { + _errorMessage = error.toString(); + } finally { + _setLoading(false); + } + } + + Future fetchStatus() async { + try { + _status = await _httpApiService.getPlaybackStatus(); + _errorMessage = null; + notifyListeners(); + } catch (error) { + _errorMessage = error.toString(); + notifyListeners(); + } + } + + Future fetchPlaylist() async { + try { + _playlist = await _httpApiService.getPlaylist(); + _errorMessage = null; + notifyListeners(); + } catch (error) { + _errorMessage = error.toString(); + notifyListeners(); + } + } + + Future play() => _runCommand(_httpApiService.play); + + Future pause() => _runCommand(_httpApiService.pause); + + Future next() => _runCommand(_httpApiService.next); + + Future previous() => _runCommand(_httpApiService.previous); + + Future gotoIndex(int index) async { + await _runCommand(() => _httpApiService.goto(index)); + } + + Future switchScene(String name) async { + await _runCommand(() => _httpApiService.changeScene(name)); + _currentState = name; + notifyListeners(); + } + + Future triggerEvent(String name, {String? value}) { + return _runCommand(() => _httpApiService.trigger(name, value)); + } + + Future togglePlayPause() async { + if (_status.running && !_status.paused) { + await pause(); + return; + } + await play(); + } + + Future _runCommand(Future Function() action) async { + _setLoading(true); + try { + await action(); + await Future.wait([ + fetchStatus(), + fetchPlaylist(), + ]); + _errorMessage = null; + } catch (error) { + _errorMessage = error.toString(); + notifyListeners(); + } finally { + _setLoading(false); + } + } + + void _updateSceneOptions(Map config) { + final candidates = {}; + final scenes = config['scenes']; + if (scenes is List) { + for (final scene in scenes) { + final value = scene.toString(); + if (value.isNotEmpty) { + candidates.add(value); + } + } + } + + final stateMachine = config['state_machine']; + if (stateMachine is Map) { + final states = stateMachine['states']; + if (states is Map) { + for (final entry in states.keys) { + final value = entry.toString(); + if (value.isNotEmpty) { + candidates.add(value); + } + } + } + final initialState = stateMachine['initial_state']?.toString(); + if (initialState != null && initialState.isNotEmpty) { + candidates.add(initialState); + _currentState ??= initialState; + } + } + + if (candidates.isNotEmpty) { + _sceneOptions = candidates.toList(growable: false)..sort(); + } + } + + void _setLoading(bool value) { + _isLoading = value; + notifyListeners(); + } + + @override + void dispose() { + unawaited(_statusSubscription.cancel()); + unawaited(_stateSubscription.cancel()); + unawaited(_configSubscription.cancel()); + super.dispose(); + } +} diff --git a/clients/flutter/lib/providers/wifi_provider.dart b/clients/flutter/lib/providers/wifi_provider.dart new file mode 100644 index 0000000..6d3aab3 --- /dev/null +++ b/clients/flutter/lib/providers/wifi_provider.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../models/wifi_network.dart'; +import '../models/wifi_status.dart'; +import '../services/http_api_service.dart'; +import '../services/web_socket_service.dart'; + +class WifiProvider extends ChangeNotifier { + WifiProvider({ + required HttpApiService httpApiService, + required WebSocketService webSocketService, + }) : _httpApiService = httpApiService, + _webSocketService = webSocketService { + _wifiSubscription = _webSocketService.onWifiUpdate.listen((payload) { + _status = WifiStatus.fromJson(payload); + notifyListeners(); + }); + } + + final HttpApiService _httpApiService; + final WebSocketService _webSocketService; + late final StreamSubscription> _wifiSubscription; + + WifiStatus _status = WifiStatus.disconnected(); + List _networks = const []; + bool _isLoading = false; + String? _errorMessage; + bool _hotspotEnabled = false; + + WifiStatus get status => _status; + List get networks => _networks; + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + bool get hotspotEnabled => _hotspotEnabled; + + Future bootstrap() async { + _setLoading(true); + try { + final results = await Future.wait([ + _httpApiService.getWifiStatus(), + _httpApiService.scanWifi(), + ]); + _status = results[0] as WifiStatus; + _networks = results[1] as List; + _errorMessage = null; + } catch (error) { + _errorMessage = error.toString(); + } finally { + _setLoading(false); + } + } + + Future refreshStatus() async { + try { + _status = await _httpApiService.getWifiStatus(); + notifyListeners(); + } catch (error) { + _errorMessage = error.toString(); + notifyListeners(); + } + } + + Future scanNetworks() async { + _setLoading(true); + try { + _networks = await _httpApiService.scanWifi(); + _errorMessage = null; + } catch (error) { + _errorMessage = error.toString(); + } finally { + _setLoading(false); + } + } + + Future connect({required String ssid, required String password}) async { + _setLoading(true); + try { + await _httpApiService.connectWifi(ssid, password); + await refreshStatus(); + _hotspotEnabled = false; + _errorMessage = null; + } catch (error) { + _errorMessage = error.toString(); + notifyListeners(); + } finally { + _setLoading(false); + } + } + + Future startHotspot({String? ssid, String? password}) async { + _setLoading(true); + try { + await _httpApiService.startAP(ssid, password); + _hotspotEnabled = true; + _errorMessage = null; + notifyListeners(); + } catch (error) { + _errorMessage = error.toString(); + notifyListeners(); + } finally { + _setLoading(false); + } + } + + Future stopHotspot() async { + _setLoading(true); + try { + await _httpApiService.stopAP(); + _hotspotEnabled = false; + _errorMessage = null; + notifyListeners(); + } catch (error) { + _errorMessage = error.toString(); + notifyListeners(); + } finally { + _setLoading(false); + } + } + + void _setLoading(bool value) { + _isLoading = value; + notifyListeners(); + } + + @override + void dispose() { + unawaited(_wifiSubscription.cancel()); + super.dispose(); + } +} diff --git a/clients/flutter/lib/screens/app_shell.dart b/clients/flutter/lib/screens/app_shell.dart new file mode 100644 index 0000000..099a849 --- /dev/null +++ b/clients/flutter/lib/screens/app_shell.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +class AppShell extends StatelessWidget { + const AppShell({required this.navigationShell, super.key}); + + final StatefulNavigationShell navigationShell; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: navigationShell, + bottomNavigationBar: NavigationBar( + selectedIndex: navigationShell.currentIndex, + onDestinationSelected: (index) { + navigationShell.goBranch( + index, + initialLocation: index == navigationShell.currentIndex, + ); + }, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: '首页', + ), + NavigationDestination( + icon: Icon(Icons.play_circle_outline), + selectedIcon: Icon(Icons.play_circle), + label: '播放', + ), + NavigationDestination( + icon: Icon(Icons.bolt_outlined), + selectedIcon: Icon(Icons.bolt), + label: '触发', + ), + NavigationDestination( + icon: Icon(Icons.wifi_outlined), + selectedIcon: Icon(Icons.wifi), + label: '网络', + ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: '设置', + ), + ], + ), + ); + } +} diff --git a/clients/flutter/lib/screens/ble_provision_screen.dart b/clients/flutter/lib/screens/ble_provision_screen.dart new file mode 100644 index 0000000..c8969ef --- /dev/null +++ b/clients/flutter/lib/screens/ble_provision_screen.dart @@ -0,0 +1,597 @@ +import 'package:flutter/material.dart'; + +import '../models/ble_models.dart'; +import '../providers/ble_provider.dart'; + +class BleProvisionScreen extends StatefulWidget { + const BleProvisionScreen({super.key, this.provider}); + + final BleProvider? provider; + + @override + State createState() => _BleProvisionScreenState(); +} + +class _BleProvisionScreenState extends State { + late final BleProvider _provider; + late final bool _ownsProvider; + final TextEditingController _ssidController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final GlobalKey _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + _ownsProvider = widget.provider == null; + _provider = widget.provider ?? BleProvider(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _provider.startScan(); + }); + } + + @override + void dispose() { + _ssidController.dispose(); + _passwordController.dispose(); + if (_ownsProvider) { + _provider.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Showen BLE 配网')), + body: AnimatedBuilder( + animation: _provider, + builder: (BuildContext context, _) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF0F172A), Color(0xFF111827)], + ), + ), + child: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _StatusBanner(provider: _provider), + const SizedBox(height: 16), + _ProgressCard(state: _provider.provisioningState), + const SizedBox(height: 16), + _buildDevicesCard(), + const SizedBox(height: 16), + _buildWifiCard(), + const SizedBox(height: 16), + _buildResultCard(), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildDevicesCard() { + return _GlassCard( + title: '1. 扫描 Showen 设备', + trailing: TextButton.icon( + onPressed: _provider.isProvisioning ? null : _provider.retryScan, + icon: _provider.isScanning + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + label: Text(_provider.isScanning ? '扫描中' : '重新扫描'), + ), + child: Column( + children: [ + if (_provider.devices.isEmpty) + const _EmptyState( + icon: Icons.bluetooth_searching, + title: '未发现 Showen 设备', + subtitle: '请确认设备已开机,且蓝牙广播名称包含 Showen。', + ) + else + ..._provider.devices.map((BleDevice device) { + final bool selected = _provider.selectedDevice?.id == device.id; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: _provider.isConnecting || _provider.isProvisioning + ? null + : () => _handleDeviceSelected(device), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected + ? const Color(0xFF22C55E) + : Colors.white24, + ), + color: selected + ? const Color(0x1A22C55E) + : const Color(0x141E293B), + ), + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Icon( + selected ? Icons.bluetooth_connected : Icons.bluetooth, + color: Colors.white, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + device.name, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + device.id, + style: const TextStyle( + color: Color(0xFF94A3B8), + fontSize: 12, + ), + ), + ], + ), + ), + _SignalBadge(rssi: device.rssi), + ], + ), + ), + ), + ); + }), + ], + ), + ); + } + + Widget _buildWifiCard() { + return _GlassCard( + title: '2. 输入 WiFi 凭据', + child: Form( + key: _formKey, + child: Column( + children: [ + TextFormField( + controller: _ssidController, + enabled: !_provider.isProvisioning, + style: const TextStyle(color: Colors.white), + decoration: _inputDecoration('WiFi SSID', Icons.wifi), + validator: (String? value) { + if ((value ?? '').trim().isEmpty) { + return '请输入 WiFi 名称'; + } + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + enabled: !_provider.isProvisioning, + obscureText: true, + style: const TextStyle(color: Colors.white), + decoration: _inputDecoration('WiFi 密码', Icons.lock_outline), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: (_provider.selectedDevice == null || + !_provider.isConnected || + _provider.isProvisioning) + ? null + : _handleProvisioning, + icon: _provider.isProvisioning + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.router_outlined), + label: Text(_provider.isProvisioning ? '配网中...' : '开始配网'), + ), + ), + ], + ), + ), + ); + } + + Widget _buildResultCard() { + final BleStatus? status = _provider.latestStatus; + final bool success = _provider.provisioningState == ProvisioningState.success; + final bool failed = _provider.provisioningState == ProvisioningState.failed; + + return _GlassCard( + title: '3. 配网结果', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + success + ? Icons.check_circle_outline + : failed + ? Icons.error_outline + : Icons.info_outline, + color: success + ? const Color(0xFF22C55E) + : failed + ? const Color(0xFFF87171) + : const Color(0xFF38BDF8), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + success + ? 'WiFi 已连接成功' + : failed + ? (_provider.errorMessage ?? '配网失败') + : '等待设备返回状态', + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + if (status != null) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.18), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white12), + ), + child: Text( + 'action: ${status.action}\nstate: ${status.state ?? '-'}\nerror: ${status.error ?? '-'}', + style: const TextStyle( + color: Color(0xFFCBD5E1), + height: 1.5, + ), + ), + ), + ], + if (failed) ...[ + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _provider.retryScan, + icon: const Icon(Icons.replay), + label: const Text('重试'), + ), + ], + ], + ), + ); + } + + Future _handleDeviceSelected(BleDevice device) async { + try { + await _provider.connectToDevice(device); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('已连接 ${device.name}')), + ); + } catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('连接失败: $error')), + ); + } + } + + Future _handleProvisioning() async { + if (!_formKey.currentState!.validate()) { + return; + } + + try { + await _provider.provisionWifi( + _ssidController.text.trim(), + _passwordController.text, + ); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('设备已完成 WiFi 配网')), + ); + } catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('配网失败: $error')), + ); + } + } + + InputDecoration _inputDecoration(String label, IconData icon) { + return InputDecoration( + labelText: label, + labelStyle: const TextStyle(color: Color(0xFFCBD5E1)), + prefixIcon: Icon(icon, color: const Color(0xFF38BDF8)), + filled: true, + fillColor: const Color(0x141E293B), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.white24), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFF38BDF8), width: 1.2), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFF87171)), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Color(0xFFF87171), width: 1.2), + ), + ); + } +} + +class _StatusBanner extends StatelessWidget { + const _StatusBanner({required this.provider}); + + final BleProvider provider; + + @override + Widget build(BuildContext context) { + final bool connected = provider.isConnected; + final String headline = connected + ? '已连接 ${provider.selectedDevice?.name ?? 'Showen 设备'}' + : provider.isScanning + ? '正在扫描附近 Showen 设备' + : '选择设备后即可开始 BLE 配网'; + + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: const LinearGradient( + colors: [Color(0xFF0EA5E9), Color(0xFF22C55E)], + ), + boxShadow: const [ + BoxShadow( + color: Color(0x330EA5E9), + blurRadius: 24, + offset: Offset(0, 10), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'ShowenV2 BLE Provisioning', + style: TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + headline, + style: const TextStyle( + color: Colors.white, + height: 1.5, + ), + ), + ], + ), + ); + } +} + +class _ProgressCard extends StatelessWidget { + const _ProgressCard({required this.state}); + + final ProvisioningState state; + + @override + Widget build(BuildContext context) { + final List<_StepMeta> steps = <_StepMeta>[ + _StepMeta('扫描设备', state.index >= ProvisioningState.scanning.index), + _StepMeta('连接设备', state.index >= ProvisioningState.connecting.index), + _StepMeta('写入凭据', state.index >= ProvisioningState.writingCredentials.index), + _StepMeta('连接 WiFi', state.index >= ProvisioningState.connectingWifi.index), + _StepMeta('完成', state == ProvisioningState.success), + ]; + + return _GlassCard( + title: '当前进度', + child: Wrap( + runSpacing: 12, + spacing: 12, + children: steps.map(( _StepMeta step) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: step.active + ? const Color(0x1A22C55E) + : Colors.black.withOpacity(0.14), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: step.active ? const Color(0xFF22C55E) : Colors.white24, + ), + ), + child: Text( + step.label, + style: TextStyle( + color: + step.active ? const Color(0xFFF8FAFC) : const Color(0xFF94A3B8), + fontWeight: FontWeight.w600, + ), + ), + ); + }).toList(), + ), + ); + } +} + +class _StepMeta { + const _StepMeta(this.label, this.active); + + final String label; + final bool active; +} + +class _GlassCard extends StatelessWidget { + const _GlassCard({ + required this.title, + required this.child, + this.trailing, + }); + + final String title; + final Widget child; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: const Color(0xB31E293B), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.white10), + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ), + if (trailing != null) trailing!, + ], + ), + const SizedBox(height: 16), + child, + ], + ), + ); + } +} + +class _SignalBadge extends StatelessWidget { + const _SignalBadge({required this.rssi}); + + final int rssi; + + @override + Widget build(BuildContext context) { + final IconData icon = switch (rssi) { + >= -60 => Icons.network_wifi, + >= -75 => Icons.network_wifi_3_bar, + >= -90 => Icons.network_wifi_2_bar, + _ => Icons.network_wifi_1_bar, + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: const Color(0xFF38BDF8), size: 18), + const SizedBox(width: 6), + Text( + '$rssi dBm', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState({ + required this.icon, + required this.title, + required this.subtitle, + }); + + final IconData icon; + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.15), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white10), + ), + child: Column( + children: [ + Icon(icon, color: const Color(0xFF38BDF8), size: 32), + const SizedBox(height: 10), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + subtitle, + textAlign: TextAlign.center, + style: const TextStyle(color: Color(0xFF94A3B8), height: 1.5), + ), + ], + ), + ); + } +} diff --git a/clients/flutter/lib/screens/home_screen.dart b/clients/flutter/lib/screens/home_screen.dart new file mode 100644 index 0000000..15fc581 --- /dev/null +++ b/clients/flutter/lib/screens/home_screen.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/device_provider.dart'; +import '../providers/player_provider.dart'; +import '../providers/wifi_provider.dart'; +import '../theme/app_colors.dart'; +import '../widgets/control_button.dart'; +import '../widgets/status_card.dart'; + +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + final deviceProvider = context.watch(); + final playerProvider = context.watch(); + final wifiProvider = context.watch(); + final device = deviceProvider.status; + final player = playerProvider.status; + final wifi = wifiProvider.status; + + return Scaffold( + appBar: AppBar(title: const Text('ShowenV2 控制台')), + body: RefreshIndicator( + onRefresh: () async { + await Future.wait([ + context.read().refresh(), + context.read().bootstrap(), + context.read().bootstrap(), + ]); + }, + child: ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + StatusCard( + title: '设备连接', + value: device.connected ? '已连接' : '未连接', + subtitle: '${device.ipAddress ?? deviceProvider.deviceIp} · ${device.connectionType.toUpperCase()}', + icon: Icons.devices_rounded, + accentColor: device.connected ? AppColors.success : AppColors.warning, + ), + const SizedBox(height: AppSpacing.md), + StatusCard( + title: '当前播放状态', + value: player.currentVideo ?? '暂无播放视频', + subtitle: player.running + ? (player.paused ? '已暂停' : '播放中') + : '等待播放', + icon: Icons.play_circle_outline_rounded, + accentColor: player.paused ? AppColors.warning : AppColors.primary, + ), + const SizedBox(height: AppSpacing.md), + StatusCard( + title: 'WiFi 摘要', + value: wifi.connected ? (wifi.ssid ?? '已连接') : '未连接网络', + subtitle: wifi.ip ?? '可通过热点或 BLE 配网', + icon: Icons.wifi_rounded, + accentColor: wifi.connected ? AppColors.info : AppColors.border, + ), + const SizedBox(height: AppSpacing.lg), + Text('快捷控制', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: ControlButton( + label: player.running && !player.paused ? '暂停' : '播放', + icon: player.running && !player.paused + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + onPressed: playerProvider.isLoading + ? null + : () => context.read().togglePlayPause(), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: ControlButton( + label: '上一个', + icon: Icons.skip_previous_rounded, + isFilled: false, + onPressed: playerProvider.isLoading + ? null + : () => context.read().previous(), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: ControlButton( + label: '下一个', + icon: Icons.skip_next_rounded, + isFilled: false, + onPressed: playerProvider.isLoading + ? null + : () => context.read().next(), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.lg), + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('连接详情', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.md), + _InfoRow(label: '设备 IP', value: device.ipAddress ?? deviceProvider.deviceIp), + _InfoRow(label: '连接方式', value: device.connectionType.toUpperCase()), + _InfoRow( + label: '实时通道', + value: deviceProvider.webSocketConnected ? 'WebSocket 已连接' : 'WebSocket 重连中', + ), + _InfoRow(label: '播放索引', value: '${player.currentIndex + 1}/${player.playlistLength}'), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + children: [ + Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)), + Text(value, style: Theme.of(context).textTheme.bodyLarge), + ], + ), + ); + } +} diff --git a/clients/flutter/lib/screens/network_screen.dart b/clients/flutter/lib/screens/network_screen.dart new file mode 100644 index 0000000..828e401 --- /dev/null +++ b/clients/flutter/lib/screens/network_screen.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../providers/device_provider.dart'; +import '../providers/wifi_provider.dart'; +import '../theme/app_colors.dart'; +import '../widgets/control_button.dart'; +import '../widgets/status_card.dart'; +import '../widgets/wifi_list_tile.dart'; + +class NetworkScreen extends StatefulWidget { + const NetworkScreen({super.key}); + + @override + State createState() => _NetworkScreenState(); +} + +class _NetworkScreenState extends State { + final TextEditingController _ssidController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _apSsidController = TextEditingController(text: 'showen'); + final TextEditingController _apPasswordController = TextEditingController(text: '12345678'); + + @override + void dispose() { + _ssidController.dispose(); + _passwordController.dispose(); + _apSsidController.dispose(); + _apPasswordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final wifiProvider = context.watch(); + final deviceProvider = context.watch(); + final wifiStatus = wifiProvider.status; + final bleStatus = deviceProvider.status.bleStatus; + + return Scaffold( + appBar: AppBar(title: const Text('网络设置')), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + StatusCard( + title: 'WiFi 状态', + value: wifiStatus.connected ? (wifiStatus.ssid ?? '已连接') : '未连接', + subtitle: wifiStatus.ip ?? '尚未获取 IP 地址', + icon: Icons.router_rounded, + accentColor: wifiStatus.connected ? AppColors.success : AppColors.warning, + ), + const SizedBox(height: AppSpacing.md), + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('连接 WiFi', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.md), + TextField( + controller: _ssidController, + decoration: const InputDecoration(labelText: 'WiFi 名称'), + ), + const SizedBox(height: AppSpacing.md), + TextField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration(labelText: 'WiFi 密码'), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: wifiProvider.isLoading ? null : _handleConnectWifi, + icon: const Icon(Icons.wifi_password_rounded), + label: const Text('连接当前 WiFi'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + Row( + children: [ + Expanded( + child: ControlButton( + label: '扫描 WiFi', + icon: Icons.wifi_find_rounded, + onPressed: wifiProvider.isLoading + ? null + : () => context.read().scanNetworks(), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: ControlButton( + label: bleStatus?.running == true ? 'BLE 已就绪' : '启动 BLE', + icon: Icons.bluetooth_rounded, + isFilled: false, + onPressed: deviceProvider.isLoading + ? null + : () => context.read().startBle(deviceName: 'showen'), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.lg), + Text('扫描结果', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: AppSpacing.md), + if (wifiProvider.networks.isEmpty) + const Card( + child: Padding( + padding: EdgeInsets.all(AppSpacing.md), + child: Text('暂无扫描结果'), + ), + ), + ...wifiProvider.networks.map( + (network) => Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: WifiListTile( + network: network, + onTap: () { + _ssidController.text = network.ssid; + setState(() {}); + }, + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text('热点开关', style: Theme.of(context).textTheme.titleMedium), + ), + Switch( + value: wifiProvider.hotspotEnabled, + onChanged: (enabled) { + if (enabled) { + context.read().startHotspot( + ssid: _apSsidController.text.trim(), + password: _apPasswordController.text, + ); + return; + } + context.read().stopHotspot(); + }, + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + TextField( + controller: _apSsidController, + decoration: const InputDecoration(labelText: '热点 SSID'), + ), + const SizedBox(height: AppSpacing.md), + TextField( + controller: _apPasswordController, + obscureText: true, + decoration: const InputDecoration(labelText: '热点密码'), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: FilledButton.tonal( + onPressed: () => context.push('/network/ble-provision'), + child: const Text('进入 BLE 配网页面'), + ), + ), + ], + ), + ); + } + + void _handleConnectWifi() { + final ssid = _ssidController.text.trim(); + if (ssid.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请输入 WiFi 名称')), + ); + return; + } + + context.read().connect( + ssid: ssid, + password: _passwordController.text, + ); + } +} diff --git a/clients/flutter/lib/screens/playback_screen.dart b/clients/flutter/lib/screens/playback_screen.dart new file mode 100644 index 0000000..9307057 --- /dev/null +++ b/clients/flutter/lib/screens/playback_screen.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/player_provider.dart'; +import '../theme/app_colors.dart'; +import '../widgets/control_button.dart'; + +class PlaybackScreen extends StatefulWidget { + const PlaybackScreen({super.key}); + + @override + State createState() => _PlaybackScreenState(); +} + +class _PlaybackScreenState extends State { + final TextEditingController _indexController = TextEditingController(); + + @override + void dispose() { + _indexController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final status = provider.status; + final playlist = provider.playlist; + + return Scaffold( + appBar: AppBar(title: const Text('播放控制')), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + children: [ + Text( + status.currentVideo ?? '暂无播放内容', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: AppSpacing.lg), + SizedBox( + width: 132, + height: 132, + child: DecoratedBox( + decoration: const BoxDecoration( + shape: BoxShape.circle, + gradient: AppColors.primaryGradient, + ), + child: IconButton( + onPressed: provider.isLoading + ? null + : () => context.read().togglePlayPause(), + iconSize: 56, + color: Colors.white, + icon: Icon( + status.running && !status.paused + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + status.running ? (status.paused ? '已暂停' : '播放中') : '未开始播放', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: ControlButton( + label: '上一个', + icon: Icons.skip_previous_rounded, + isFilled: false, + onPressed: provider.isLoading + ? null + : () => context.read().previous(), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: ControlButton( + label: '下一个', + icon: Icons.skip_next_rounded, + isFilled: false, + onPressed: provider.isLoading + ? null + : () => context.read().next(), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.lg), + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('跳转到指定索引', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: TextField( + controller: _indexController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: '输入 0 开始的索引', + ), + ), + ), + const SizedBox(width: AppSpacing.md), + FilledButton( + onPressed: provider.isLoading ? null : _handleGoto, + child: const Text('跳转'), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + Text('播放列表', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: AppSpacing.md), + if (playlist.isEmpty) + const Card( + child: Padding( + padding: EdgeInsets.all(AppSpacing.md), + child: Text('当前没有可播放视频'), + ), + ), + ...playlist.asMap().entries.map((entry) { + final selected = entry.key == status.currentIndex; + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Card( + color: selected ? AppColors.primary.withOpacity(0.16) : null, + child: ListTile( + onTap: provider.isLoading + ? null + : () => context.read().gotoIndex(entry.key), + leading: CircleAvatar( + backgroundColor: selected ? AppColors.primary : AppColors.border, + child: Text('${entry.key + 1}'), + ), + title: Text(entry.value), + subtitle: Text(selected ? '当前播放' : '点击跳转'), + trailing: Icon( + selected ? Icons.equalizer_rounded : Icons.chevron_right_rounded, + ), + ), + ), + ); + }), + ], + ), + ); + } + + void _handleGoto() { + final index = int.tryParse(_indexController.text.trim()); + if (index == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请输入有效索引')), + ); + return; + } + context.read().gotoIndex(index); + } +} diff --git a/clients/flutter/lib/screens/settings_screen.dart b/clients/flutter/lib/screens/settings_screen.dart new file mode 100644 index 0000000..ebca5b5 --- /dev/null +++ b/clients/flutter/lib/screens/settings_screen.dart @@ -0,0 +1,338 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/device_provider.dart'; +import '../theme/app_colors.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({super.key}); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + final TextEditingController _ipController = TextEditingController(); + final TextEditingController _titleController = TextEditingController(); + final TextEditingController _rotationController = TextEditingController(); + final TextEditingController _widthController = TextEditingController(); + final TextEditingController _heightController = TextEditingController(); + final TextEditingController _hsvMinController = TextEditingController(); + final TextEditingController _hsvMaxController = TextEditingController(); + final TextEditingController _pointsController = TextEditingController(); + + Map? _fullConfig; + List _availableConfigs = const []; + String? _activeConfig; + bool _isFullscreen = false; + bool _loading = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadData()); + } + + @override + void dispose() { + _ipController.dispose(); + _titleController.dispose(); + _rotationController.dispose(); + _widthController.dispose(); + _heightController.dispose(); + _hsvMinController.dispose(); + _hsvMaxController.dispose(); + _pointsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final status = provider.status; + _ipController.text = _ipController.text.isEmpty ? provider.deviceIp : _ipController.text; + + return Scaffold( + appBar: AppBar(title: const Text('设置')), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('设备 IP 配置', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.md), + TextField( + controller: _ipController, + decoration: const InputDecoration(labelText: '设备 IP 地址'), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () async { + await context.read().updateDeviceIp( + _ipController.text.trim(), + ); + if (!mounted) { + return; + } + await _loadData(); + }, + child: const Text('保存并重连'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('可用配置文件', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.md), + DropdownButtonFormField( + value: _activeConfig, + items: _availableConfigs + .map( + (item) => DropdownMenuItem( + value: item, + child: Text(item), + ), + ) + .toList(growable: false), + onChanged: (value) => setState(() => _activeConfig = value), + decoration: const InputDecoration(labelText: '当前配置'), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: FilledButton.tonal( + onPressed: _activeConfig == null ? null : _handleSwitchConfig, + child: const Text('切换配置'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('显示设置', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.md), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _isFullscreen, + onChanged: (value) => setState(() => _isFullscreen = value), + title: const Text('全屏模式'), + ), + TextField( + controller: _titleController, + decoration: const InputDecoration(labelText: '窗口标题'), + ), + const SizedBox(height: AppSpacing.md), + Row( + children: [ + Expanded( + child: TextField( + controller: _rotationController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '旋转角度'), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: TextField( + controller: _widthController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '渲染宽度'), + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: TextField( + controller: _heightController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '渲染高度'), + ), + ), + ], + ), + const SizedBox(height: AppSpacing.md), + TextField( + controller: _hsvMinController, + decoration: const InputDecoration(labelText: '色键下限 HSV (逗号分隔)'), + ), + const SizedBox(height: AppSpacing.md), + TextField( + controller: _hsvMaxController, + decoration: const InputDecoration(labelText: '色键上限 HSV (逗号分隔)'), + ), + const SizedBox(height: AppSpacing.md), + TextField( + controller: _pointsController, + minLines: 3, + maxLines: 5, + decoration: const InputDecoration(labelText: '透视点 JSON'), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _handleSaveDisplayConfig, + child: const Text('保存显示设置'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('关于信息', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.md), + _InfoRow(label: '设备名称', value: status.deviceName ?? 'ShowenV2'), + _InfoRow(label: '连接方式', value: status.connectionType.toUpperCase()), + _InfoRow(label: '设备地址', value: status.ipAddress ?? provider.deviceIp), + _InfoRow( + label: '实时通道', + value: provider.webSocketConnected ? '已连接' : '未连接', + ), + ], + ), + ), + ), + ], + ), + ); + } + + Future _loadData() async { + final service = context.read().httpApiService; + setState(() => _loading = true); + try { + final results = await Future.wait([ + service.getConfig(), + service.getAvailableConfigs(), + ]); + _fullConfig = Map.from(results[0] as Map); + final available = Map.from(results[1] as Map); + _availableConfigs = (available['configs'] as List? ?? const []) + .map((item) => item.toString()) + .toList(growable: false); + _activeConfig = available['active']?.toString(); + _applyDisplayConfig(Map.from(_fullConfig?['display'] as Map? ?? const {})); + } finally { + if (mounted) { + setState(() => _loading = false); + } + } + } + + Future _handleSwitchConfig() async { + final activeConfig = _activeConfig; + if (activeConfig == null) { + return; + } + + await context.read().httpApiService.switchConfig(activeConfig); + if (!mounted) { + return; + } + await _loadData(); + } + + Future _handleSaveDisplayConfig() async { + final config = _fullConfig; + if (config == null) { + return; + } + + final nextConfig = Map.from(config); + nextConfig['display'] = { + ...Map.from(config['display'] as Map? ?? const {}), + 'fullscreen': _isFullscreen, + 'window_title': _titleController.text.trim(), + 'rotation': int.tryParse(_rotationController.text.trim()) ?? 0, + 'render_width': int.tryParse(_widthController.text.trim()) ?? 1024, + 'render_height': int.tryParse(_heightController.text.trim()) ?? 1024, + 'chroma_key': { + 'hsv_min': _parseIntList(_hsvMinController.text), + 'hsv_max': _parseIntList(_hsvMaxController.text), + }, + 'perspective_correction': { + 'points': jsonDecode(_pointsController.text.trim().isEmpty ? '[]' : _pointsController.text.trim()), + }, + }; + + await context.read().httpApiService.updateConfig(nextConfig); + _fullConfig = nextConfig; + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('显示设置已保存')), + ); + } + + void _applyDisplayConfig(Map display) { + _isFullscreen = display['fullscreen'] as bool? ?? false; + _titleController.text = display['window_title']?.toString() ?? ''; + _rotationController.text = '${display['rotation'] ?? 0}'; + _widthController.text = '${display['render_width'] ?? 1024}'; + _heightController.text = '${display['render_height'] ?? 1024}'; + final chromaKey = Map.from(display['chroma_key'] as Map? ?? const {}); + _hsvMinController.text = (chromaKey['hsv_min'] as List? ?? const [0, 0, 200]).join(','); + _hsvMaxController.text = (chromaKey['hsv_max'] as List? ?? const [180, 30, 255]).join(','); + final perspective = Map.from(display['perspective_correction'] as Map? ?? const {}); + _pointsController.text = jsonEncode(perspective['points'] ?? const []); + } + + List _parseIntList(String raw) { + return raw + .split(',') + .map((item) => int.tryParse(item.trim()) ?? 0) + .toList(growable: false); + } +} + +class _InfoRow extends StatelessWidget { + const _InfoRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Row( + children: [ + Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)), + Text(value, style: Theme.of(context).textTheme.bodyLarge), + ], + ), + ); + } +} diff --git a/clients/flutter/lib/screens/trigger_screen.dart b/clients/flutter/lib/screens/trigger_screen.dart new file mode 100644 index 0000000..f4c9df6 --- /dev/null +++ b/clients/flutter/lib/screens/trigger_screen.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/player_provider.dart'; +import '../theme/app_colors.dart'; + +class TriggerScreen extends StatefulWidget { + const TriggerScreen({super.key}); + + @override + State createState() => _TriggerScreenState(); +} + +class _TriggerScreenState extends State { + final TextEditingController _triggerController = TextEditingController(); + final TextEditingController _valueController = TextEditingController(); + String? _selectedScene; + + static const List<_PresetTrigger> _presets = <_PresetTrigger>[ + _PresetTrigger(label: '语音唤醒', name: 'wake', icon: Icons.mic_rounded), + _PresetTrigger(label: '按钮 1', name: 'button1', icon: Icons.filter_1_rounded), + _PresetTrigger(label: '按钮 2', name: 'button2', icon: Icons.filter_2_rounded), + _PresetTrigger(label: '触摸传感器', name: 'touch', icon: Icons.touch_app_rounded), + ]; + + @override + void dispose() { + _triggerController.dispose(); + _valueController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + _selectedScene ??= provider.sceneOptions.isNotEmpty ? provider.sceneOptions.first : null; + + return Scaffold( + appBar: AppBar(title: const Text('状态机触发')), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.md), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('当前状态', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.sm), + Text( + provider.currentState, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: AppColors.primary, + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + Text('预设触发器', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: AppSpacing.md), + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: AppSpacing.md, + mainAxisSpacing: AppSpacing.md, + childAspectRatio: 1.35, + children: _presets.map((preset) { + return InkWell( + borderRadius: BorderRadius.circular(AppRadius.large), + onTap: provider.isLoading + ? null + : () => context.read().triggerEvent(preset.name), + child: Ink( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.large), + border: Border.all(color: AppColors.border), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(preset.icon, color: AppColors.accent, size: 32), + const SizedBox(height: AppSpacing.sm), + Text(preset.label), + ], + ), + ), + ); + }).toList(growable: false), + ), + const SizedBox(height: AppSpacing.lg), + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('自定义触发器', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.md), + TextField( + controller: _triggerController, + decoration: const InputDecoration(labelText: '触发器名称'), + ), + const SizedBox(height: AppSpacing.md), + TextField( + controller: _valueController, + decoration: const InputDecoration(labelText: '可选参数值'), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: provider.isLoading ? null : _handleCustomTrigger, + icon: const Icon(Icons.send_rounded), + label: const Text('发送触发器'), + ), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.lg), + Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('场景切换', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.md), + DropdownButtonFormField( + value: provider.sceneOptions.contains(_selectedScene) ? _selectedScene : null, + items: provider.sceneOptions + .map( + (scene) => DropdownMenuItem( + value: scene, + child: Text(scene), + ), + ) + .toList(growable: false), + onChanged: (value) => setState(() => _selectedScene = value), + decoration: const InputDecoration(labelText: '选择场景'), + ), + const SizedBox(height: AppSpacing.md), + SizedBox( + width: double.infinity, + child: FilledButton.tonal( + onPressed: provider.isLoading || _selectedScene == null + ? null + : () => context.read().switchScene(_selectedScene!), + child: const Text('切换场景'), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + void _handleCustomTrigger() { + final name = _triggerController.text.trim(); + final value = _valueController.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请输入触发器名称')), + ); + return; + } + + context.read().triggerEvent( + name, + value: value.isEmpty ? null : value, + ); + } +} + +class _PresetTrigger { + const _PresetTrigger({ + required this.label, + required this.name, + required this.icon, + }); + + final String label; + final String name; + final IconData icon; +} diff --git a/clients/flutter/lib/services/ble_service.dart b/clients/flutter/lib/services/ble_service.dart new file mode 100644 index 0000000..1d0fb3b --- /dev/null +++ b/clients/flutter/lib/services/ble_service.dart @@ -0,0 +1,215 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; + +import '../models/ble_models.dart'; + +class BleService { + static final Guid _provisionServiceUuid = + Guid('12345678-1234-5678-1234-56789abcdef0'); + static final Guid _ssidCharacteristicUuid = + Guid('12345678-1234-5678-1234-56789abcdef1'); + static final Guid _passwordCharacteristicUuid = + Guid('12345678-1234-5678-1234-56789abcdef2'); + static final Guid _commandCharacteristicUuid = + Guid('12345678-1234-5678-1234-56789abcdef3'); + static final Guid _statusCharacteristicUuid = + Guid('12345678-1234-5678-1234-56789abcdef4'); + + BluetoothDevice? _connectedDevice; + BluetoothCharacteristic? _ssidCharacteristic; + BluetoothCharacteristic? _passwordCharacteristic; + BluetoothCharacteristic? _commandCharacteristic; + BluetoothCharacteristic? _statusCharacteristic; + StreamSubscription>? _scanSubscription; + + Stream> scanForShowenDevices({ + Duration timeout = const Duration(seconds: 6), + }) { + final controller = StreamController>(); + final seen = {}; + + unawaited(_scanSubscription?.cancel()); + _scanSubscription = FlutterBluePlus.scanResults.listen((results) { + for (final result in results) { + final name = _deviceName(result); + if (name.isEmpty || !name.toLowerCase().contains('showen')) { + continue; + } + final id = result.device.remoteId.str; + seen[id] = BleDevice(name: name, id: id, rssi: result.rssi); + } + + final devices = seen.values.toList(growable: false) + ..sort((a, b) => b.rssi.compareTo(a.rssi)); + if (!controller.isClosed) { + controller.add(devices); + } + }); + + unawaited(FlutterBluePlus.startScan(timeout: timeout)); + Future.delayed(timeout, () async { + await FlutterBluePlus.stopScan(); + if (!controller.isClosed) { + await controller.close(); + } + }); + + controller.onCancel = () async { + await FlutterBluePlus.stopScan(); + await _scanSubscription?.cancel(); + _scanSubscription = null; + }; + + return controller.stream; + } + + Future connectToDevice(BleDevice device) async { + await disconnect(); + final bluetoothDevice = BluetoothDevice.fromId(device.id); + await bluetoothDevice.connect(timeout: const Duration(seconds: 12)); + _connectedDevice = bluetoothDevice; + await _discoverCharacteristics(bluetoothDevice); + } + + Future> subscribeToStatus() async { + final characteristic = _statusCharacteristic; + if (characteristic == null) { + return Stream.value( + const BleStatus( + ok: false, + action: 'status', + error: '未发现 BLE 状态特征值', + ), + ); + } + + await characteristic.setNotifyValue(true); + return characteristic.lastValueStream + .where((value) => value.isNotEmpty) + .map((value) { + final raw = utf8.decode(value, allowMalformed: true); + try { + return BleStatus.fromRawJson(raw); + } catch (_) { + return BleStatus( + ok: true, + action: 'status', + state: raw, + ); + } + }); + } + + Future provisionWifi( + String ssid, + String password, { + Duration timeout = const Duration(seconds: 30), + }) async { + final ssidChar = _ssidCharacteristic; + final passwordChar = _passwordCharacteristic; + final command = _commandCharacteristic; + + if (_connectedDevice == null) { + throw StateError('未连接 BLE 设备'); + } + if (ssidChar == null || passwordChar == null || command == null) { + throw StateError('未发现 BLE 配网特征值'); + } + + final statusStream = await subscribeToStatus(); + final completer = Completer(); + late final StreamSubscription subscription; + subscription = statusStream.listen((status) { + if (!status.isQueued && !completer.isCompleted) { + completer.complete(status); + } + }, onError: (Object error, StackTrace stackTrace) { + if (!completer.isCompleted) { + completer.completeError(error, stackTrace); + } + }); + + await ssidChar.write( + utf8.encode(ssid), + withoutResponse: ssidChar.properties.writeWithoutResponse, + ); + await passwordChar.write( + utf8.encode(password), + withoutResponse: passwordChar.properties.writeWithoutResponse, + ); + await command.write( + utf8.encode('connect'), + withoutResponse: command.properties.writeWithoutResponse, + ); + + try { + return await completer.future.timeout(timeout); + } finally { + await subscription.cancel(); + } + } + + Future disconnect() async { + await _scanSubscription?.cancel(); + _scanSubscription = null; + + final device = _connectedDevice; + _connectedDevice = null; + _ssidCharacteristic = null; + _passwordCharacteristic = null; + _commandCharacteristic = null; + _statusCharacteristic = null; + + if (device != null) { + await device.disconnect(); + } + } + + Future dispose() => disconnect(); + + Future _discoverCharacteristics(BluetoothDevice device) async { + final services = await device.discoverServices(); + + BluetoothCharacteristic? ssid; + BluetoothCharacteristic? password; + BluetoothCharacteristic? command; + BluetoothCharacteristic? status; + + for (final service in services) { + final shouldInspect = service.uuid == _provisionServiceUuid || + services.length == 1 || + service.characteristics.isNotEmpty; + if (!shouldInspect) { + continue; + } + + for (final characteristic in service.characteristics) { + if (characteristic.uuid == _ssidCharacteristicUuid) { + ssid = characteristic; + } else if (characteristic.uuid == _passwordCharacteristicUuid) { + password = characteristic; + } else if (characteristic.uuid == _commandCharacteristicUuid) { + command = characteristic; + } else if (characteristic.uuid == _statusCharacteristicUuid) { + status = characteristic; + } + } + } + + _ssidCharacteristic = ssid; + _passwordCharacteristic = password; + _commandCharacteristic = command; + _statusCharacteristic = status; + } + + String _deviceName(ScanResult result) { + final platformName = result.device.platformName.trim(); + if (platformName.isNotEmpty) { + return platformName; + } + final advertisedName = result.advertisementData.advName.trim(); + return advertisedName; + } +} diff --git a/clients/flutter/lib/services/http_api_service.dart b/clients/flutter/lib/services/http_api_service.dart new file mode 100644 index 0000000..038d24b --- /dev/null +++ b/clients/flutter/lib/services/http_api_service.dart @@ -0,0 +1,503 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; + +import '../models/api_response.dart'; +import '../models/ble_status.dart'; +import '../models/player_status.dart'; +import '../models/video_item.dart'; +import '../models/wifi_network.dart'; +import '../models/wifi_status.dart'; + +class HttpApiService { + HttpApiService({required String baseUrl, http.Client? client}) + : _baseUrl = _normalizeBaseUrl(baseUrl), + _client = client ?? http.Client(); + + final http.Client _client; + String _baseUrl; + + String get baseUrl => _baseUrl; + + set baseUrl(String value) { + _baseUrl = _normalizeBaseUrl(value); + } + + Uri _uri(String path, [Map? queryParameters]) { + final normalizedPath = path.startsWith('/') ? path : '/$path'; + return Uri.parse('$_baseUrl$normalizedPath').replace( + queryParameters: queryParameters == null || queryParameters.isEmpty + ? null + : queryParameters, + ); + } + + Future fetchConsolePage() async { + final response = await _client.get(_uri('/')); + _ensureSuccess(response); + return response.body; + } + + Future fetchConsoleIndexHtml() async { + final response = await _client.get(_uri('/index.html')); + _ensureSuccess(response); + return response.body; + } + + Future getStatus() => getPlaybackStatus(); + + Future getPlaybackStatus() async { + final response = await _client.get(_uri('/api/status')); + _ensureSuccess(response); + return PlayerStatus.fromJson(_decodeMap(response.body)); + } + + Future play() => _postCommand('/api/play'); + + Future pause() => _postCommand('/api/pause'); + + Future next() => _postCommand('/api/next'); + + Future previous() => _postCommand('/api/previous'); + + Future goto(int index) => _postCommand('/api/goto/$index'); + + Future gotoIndex(int index) => goto(index); + + Future> getPlaylist() async { + final response = await _client.get(_uri('/api/playlist')); + _ensureSuccess(response); + final decoded = _decodeJson(response.body); + if (decoded is! List) { + return const []; + } + + return decoded.map((dynamic item) { + if (item is String) { + return item; + } + if (item is Map) { + return item['name']?.toString() ?? jsonEncode(item); + } + if (item is Map) { + return item['name']?.toString() ?? jsonEncode(item); + } + return item.toString(); + }).toList(growable: false); + } + + Future changeScene(String name) { + return _postCommand('/api/scene/${Uri.encodeComponent(name)}'); + } + + Future switchScene(String name) => changeScene(name); + + Future trigger(String name, [String? value]) { + final suffix = value == null || value.isEmpty + ? '' + : '/${Uri.encodeComponent(value)}'; + return _postCommand('/api/trigger/${Uri.encodeComponent(name)}$suffix'); + } + + Future triggerEvent(String name, {String? value}) { + return trigger(name, value); + } + + Future getWifiStatus() async { + final response = await _client.get(_uri('/api/wifi/status')); + _ensureSuccess(response); + return WifiStatus.fromJson(_decodeMap(response.body)); + } + + Future> scanWifi() async { + try { + final response = await _client.get(_uri('/api/wifi/scan')); + _ensureSuccess(response); + return _decodeWifiNetworks(response.body); + } on ApiException { + final response = await _client.post(_uri('/api/wifi/scan')); + _ensureSuccess(response); + return _decodeWifiNetworks(response.body); + } + } + + Future> scanWifiViaGet() => scanWifi(); + + Future> scanWifiViaPost() => scanWifi(); + + Future connectWifi(String ssid, String password) async { + final response = await _client.post( + _uri('/api/wifi/connect'), + headers: _jsonHeaders, + body: jsonEncode({ + 'ssid': ssid, + 'password': password, + }), + ); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + + Future startAP([String? ssid, String? password]) async { + final body = { + if (ssid != null && ssid.isNotEmpty) 'ssid': ssid, + if (password != null && password.isNotEmpty) 'password': password, + }; + + try { + final response = await _client.post( + _uri('/api/wifi/ap/start'), + headers: _jsonHeaders, + body: jsonEncode(body), + ); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } on ApiException { + final response = await _client.post( + _uri('/api/wifi/hotspot/start'), + headers: _jsonHeaders, + body: jsonEncode(body), + ); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + } + + Future startAccessPoint({String? ssid, String? password}) { + return startAP(ssid, password); + } + + Future startHotspot({String? ssid, String? password}) { + return startAP(ssid, password); + } + + Future stopAP() async { + try { + return await _postCommand('/api/wifi/ap/stop'); + } on ApiException { + return _postCommand('/api/wifi/hotspot/stop'); + } + } + + Future stopAccessPoint() => stopAP(); + + Future stopHotspot() => stopAP(); + + Future getBleStatus() async { + final response = await _client.get(_uri('/api/ble/status')); + _ensureSuccess(response); + return BleServiceStatus.fromJson(_decodeMap(response.body)); + } + + Future startBle([String? deviceName]) async { + final response = await _client.post( + _uri('/api/ble/start'), + headers: _jsonHeaders, + body: jsonEncode({ + if (deviceName != null && deviceName.isNotEmpty) + 'device_name': deviceName, + }), + ); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + + Future stopBle() => _postCommand('/api/ble/stop'); + + Future> getVideos() async { + final response = await _client.get(_uri('/api/videos')); + _ensureSuccess(response); + final decoded = _decodeJson(response.body); + if (decoded is! List) { + return const []; + } + + return decoded.map((dynamic item) { + if (item is Map) { + return VideoItem.fromJson(item); + } + if (item is Map) { + return VideoItem.fromJson(Map.from(item)); + } + return const VideoItem(name: '', size: 0); + }).where((item) => item.name.isNotEmpty).toList(growable: false); + } + + Future uploadVideo(File file) { + return _uploadSingleFile('/api/videos/upload', file); + } + + Future uploadVideos(List filePaths) async { + if (filePaths.isEmpty) { + throw const ApiException('未选择上传文件'); + } + + final request = http.MultipartRequest('POST', _uri('/api/videos/upload')); + for (final filePath in filePaths) { + request.files.add(await http.MultipartFile.fromPath('file', filePath)); + } + + final response = await http.Response.fromStream(await request.send()); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + + Future deleteVideo(String name) { + return _deleteCommand('/api/videos/${Uri.encodeComponent(name)}'); + } + + Future> getConfig() async { + final response = await _client.get(_uri('/api/config')); + _ensureSuccess(response); + return _decodeMap(response.body); + } + + Future updateConfig(Map config) async { + final response = await _client.post( + _uri('/api/config'), + headers: _jsonHeaders, + body: jsonEncode(config), + ); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + + Future> getDisplayConfig() async { + final response = await _client.get(_uri('/api/config/display')); + _ensureSuccess(response); + return _decodeMap(response.body); + } + + Future> getAvailableConfigs() async { + final response = await _client.get(_uri('/api/config/available')); + _ensureSuccess(response); + return _decodeMap(response.body); + } + + Future switchConfig(String filename) async { + final response = await _client.post( + _uri('/api/config/switch'), + headers: _jsonHeaders, + body: jsonEncode({'filename': filename}), + ); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + + Future>> listFiles(String dirKey, [String? path]) async { + final response = await _client.get( + _uri('/api/files/$dirKey', _pathQuery(path)), + ); + _ensureSuccess(response); + final decoded = _decodeJson(response.body); + if (decoded is! List) { + return const >[]; + } + + return decoded + .whereType() + .map((entry) => Map.from(entry)) + .toList(growable: false); + } + + Future uploadFile(String dirKey, File file, [String? path]) { + return _uploadSingleFile( + '/api/files/$dirKey/upload', + file, + directoryPath: path, + ); + } + + Future downloadFile(String dirKey, String path) async { + final response = await _client.get( + _uri('/api/files/$dirKey/download', {'path': path}), + ); + _ensureSuccess(response, expectJson: false); + return response.bodyBytes; + } + + Future deleteFile(String dirKey, String path) async { + final response = await _client.post( + _uri('/api/files/$dirKey/delete'), + headers: _jsonHeaders, + body: jsonEncode({'path': path}), + ); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + + Future mkdir(String dirKey, String path) async { + final response = await _client.post( + _uri('/api/files/$dirKey/mkdir'), + headers: _jsonHeaders, + body: jsonEncode({'path': path}), + ); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + + Future>> getPlugins() async { + final response = await _client.get(_uri('/api/plugins')); + _ensureSuccess(response); + final decoded = _decodeJson(response.body); + if (decoded is! List) { + return const >[]; + } + + return decoded + .whereType() + .map((plugin) => Map.from(plugin)) + .toList(growable: false); + } + + Future enablePlugin(String id) { + return _postCommand('/api/plugins/${Uri.encodeComponent(id)}/enable'); + } + + Future disablePlugin(String id) { + return _postCommand('/api/plugins/${Uri.encodeComponent(id)}/disable'); + } + + Future installPlugin(String id, [String? version]) async { + final response = await _client.post( + _uri('/api/plugins/install'), + headers: _jsonHeaders, + body: jsonEncode({ + 'id': id, + if (version != null && version.isNotEmpty) 'version': version, + }), + ); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + + Future _uploadSingleFile( + String endpoint, + File file, { + String? directoryPath, + }) async { + final request = http.MultipartRequest( + 'POST', + _uri(endpoint, _pathQuery(directoryPath)), + ); + request.files.add(await http.MultipartFile.fromPath('file', file.path)); + final response = await http.Response.fromStream(await request.send()); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + + Future _postCommand(String path) async { + final response = await _client.post(_uri(path)); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + + Future _deleteCommand(String path) async { + final response = await _client.delete(_uri(path)); + _ensureSuccess(response); + return ApiResponse.fromJson(_decodeMap(response.body)); + } + + List _decodeWifiNetworks(String body) { + final decoded = _decodeJson(body); + if (decoded is! List) { + return const []; + } + + return decoded.map((dynamic item) { + if (item is Map) { + return WifiNetwork.fromJson(item); + } + if (item is Map) { + return WifiNetwork.fromJson(Map.from(item)); + } + return const WifiNetwork(ssid: '', signal: 0, security: 'Unknown'); + }).where((network) => network.ssid.isNotEmpty).toList(growable: false); + } + + dynamic _decodeJson(String body) { + try { + return jsonDecode(body); + } on FormatException catch (error) { + throw ApiException('JSON 解析失败: ${error.message}'); + } + } + + Map _decodeMap(String body) { + final decoded = _decodeJson(body); + if (decoded is Map) { + return decoded; + } + if (decoded is Map) { + return Map.from(decoded); + } + throw const ApiException('期望返回 JSON 对象'); + } + + Map? _pathQuery(String? path) { + if (path == null || path.isEmpty) { + return null; + } + return {'path': path}; + } + + void _ensureSuccess(http.Response response, {bool expectJson = true}) { + if (response.statusCode >= 200 && response.statusCode < 300) { + return; + } + + if (!expectJson) { + throw ApiException( + '请求失败 (${response.statusCode}): ${response.body}', + statusCode: response.statusCode, + ); + } + + String message = response.body; + try { + final decoded = _decodeJson(response.body); + if (decoded is Map) { + message = decoded['message']?.toString() ?? response.body; + } + } on ApiException { + message = response.body; + } + + throw ApiException(message, statusCode: response.statusCode); + } + + Map get _jsonHeaders => const { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + + void dispose() { + _client.close(); + } + + static String _normalizeBaseUrl(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + throw const ApiException('baseUrl 不能为空'); + } + + final withScheme = trimmed.startsWith('http://') || trimmed.startsWith('https://') + ? trimmed + : 'http://$trimmed'; + return withScheme.endsWith('/') + ? withScheme.substring(0, withScheme.length - 1) + : withScheme; + } +} + +class ApiException implements Exception { + const ApiException(this.message, {this.statusCode}); + + final String message; + final int? statusCode; + + @override + String toString() => message; +} diff --git a/clients/flutter/lib/services/web_socket_service.dart b/clients/flutter/lib/services/web_socket_service.dart new file mode 100644 index 0000000..d68504c --- /dev/null +++ b/clients/flutter/lib/services/web_socket_service.dart @@ -0,0 +1,172 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../models/app_event.dart'; + +enum SocketConnectionStatus { disconnected, connecting, connected } + +class WebSocketService { + WebSocketChannel? _channel; + StreamSubscription? _subscription; + Timer? _reconnectTimer; + String? _deviceIp; + bool _manualDisconnect = false; + SocketConnectionStatus _connectionStatus = SocketConnectionStatus.disconnected; + + final StreamController _eventController = + StreamController.broadcast(); + final StreamController> _statusController = + StreamController>.broadcast(); + final StreamController> _stateController = + StreamController>.broadcast(); + final StreamController> _configController = + StreamController>.broadcast(); + final StreamController> _wifiController = + StreamController>.broadcast(); + final StreamController> _bleController = + StreamController>.broadcast(); + final StreamController _connectionController = + StreamController.broadcast(); + + Stream get events => _eventController.stream; + Stream> get onStatusUpdate => _statusController.stream; + Stream> get onStateUpdate => _stateController.stream; + Stream> get onConfigUpdate => _configController.stream; + Stream> get onWifiUpdate => _wifiController.stream; + Stream> get onBleUpdate => _bleController.stream; + Stream get onConnectionChanged => + _connectionController.stream; + + SocketConnectionStatus get connectionStatus => _connectionStatus; + bool get isConnected => _connectionStatus == SocketConnectionStatus.connected; + + Future connect(String deviceIp) async { + _manualDisconnect = false; + _deviceIp = _normalizeDeviceIp(deviceIp); + _reconnectTimer?.cancel(); + + await _subscription?.cancel(); + await _channel?.sink.close(); + + _setConnectionStatus(SocketConnectionStatus.connecting); + + final url = Uri.parse('ws://$_deviceIp:8080/ws'); + _channel = WebSocketChannel.connect(url); + _subscription = _channel!.stream.listen( + _handleMessage, + onDone: _handleSocketClosed, + onError: (_) => _handleSocketClosed(), + cancelOnError: true, + ); + + _setConnectionStatus(SocketConnectionStatus.connected); + } + + void sendCommand(Map command) { + if (!isConnected || _channel == null) { + throw StateError('WebSocket 未连接'); + } + _channel!.sink.add(jsonEncode(command)); + } + + Future reconnect() async { + final deviceIp = _deviceIp; + if (deviceIp == null || deviceIp.isEmpty || _manualDisconnect) { + return; + } + + _reconnectTimer?.cancel(); + _reconnectTimer = Timer(const Duration(seconds: 2), () { + unawaited(connect(deviceIp)); + }); + } + + Future disconnect() async { + _manualDisconnect = true; + _reconnectTimer?.cancel(); + await _subscription?.cancel(); + _subscription = null; + await _channel?.sink.close(); + _channel = null; + _setConnectionStatus(SocketConnectionStatus.disconnected); + } + + Future dispose() async { + await disconnect(); + await _eventController.close(); + await _statusController.close(); + await _stateController.close(); + await _configController.close(); + await _wifiController.close(); + await _bleController.close(); + await _connectionController.close(); + } + + void _handleMessage(dynamic data) { + final raw = data is String ? data : utf8.decode(data as List); + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return; + } + + final event = AppEvent.fromJson(Map.from(decoded)); + _eventController.add(event); + + switch (event.type) { + case 'status_update': + _statusController.add(event.payload); + break; + case 'state_update': + _stateController.add(event.payload); + break; + case 'config_update': + _configController.add(event.payload); + break; + case 'wifi_update': + _wifiController.add(event.payload); + break; + case 'ble_update': + _bleController.add(event.payload); + break; + } + } + + void _handleSocketClosed() { + _channel = null; + _subscription = null; + _setConnectionStatus(SocketConnectionStatus.disconnected); + unawaited(reconnect()); + } + + void _setConnectionStatus(SocketConnectionStatus status) { + _connectionStatus = status; + _connectionController.add(status); + } + + String _normalizeDeviceIp(String raw) { + var value = raw.trim(); + if (value.startsWith('ws://')) { + value = value.substring(5); + } else if (value.startsWith('wss://')) { + value = value.substring(6); + } else if (value.startsWith('http://')) { + value = value.substring(7); + } else if (value.startsWith('https://')) { + value = value.substring(8); + } + + final slashIndex = value.indexOf('/'); + if (slashIndex >= 0) { + value = value.substring(0, slashIndex); + } + + final colonIndex = value.indexOf(':'); + if (colonIndex >= 0) { + value = value.substring(0, colonIndex); + } + + return value; + } +} diff --git a/clients/flutter/lib/theme/app_colors.dart b/clients/flutter/lib/theme/app_colors.dart new file mode 100644 index 0000000..bd82e1b --- /dev/null +++ b/clients/flutter/lib/theme/app_colors.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class AppColors { + static const Color primary = Color(0xFF6366F1); + static const Color secondary = Color(0xFF8B5CF6); + static const Color accent = Color(0xFFEC4899); + + static const Color success = Color(0xFF10B981); + static const Color warning = Color(0xFFF59E0B); + static const Color error = Color(0xFFEF4444); + static const Color info = Color(0xFF3B82F6); + + static const Color background = Color(0xFF0F172A); + static const Color card = Color(0xFF1E293B); + static const Color border = Color(0xFF334155); + static const Color textPrimary = Color(0xFFF1F5F9); + static const Color textSecondary = Color(0xFF94A3B8); + + static const LinearGradient primaryGradient = LinearGradient( + colors: [primary, secondary], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ); +} + +class AppRadius { + static const double small = 4; + static const double medium = 8; + static const double large = 16; +} + +class AppSpacing { + static const double xs = 4; + static const double sm = 8; + static const double md = 16; + static const double lg = 24; + static const double xl = 32; + static const double xxl = 48; +} diff --git a/clients/flutter/lib/theme/app_theme.dart b/clients/flutter/lib/theme/app_theme.dart new file mode 100644 index 0000000..844c6b1 --- /dev/null +++ b/clients/flutter/lib/theme/app_theme.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; + +import 'app_colors.dart'; + +class AppTheme { + static ThemeData dark() { + const colorScheme = ColorScheme.dark( + primary: AppColors.primary, + secondary: AppColors.secondary, + surface: AppColors.card, + error: AppColors.error, + onPrimary: Colors.white, + onSecondary: Colors.white, + onSurface: AppColors.textPrimary, + onError: Colors.white, + ); + + final base = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: colorScheme, + scaffoldBackgroundColor: AppColors.background, + canvasColor: AppColors.background, + splashColor: AppColors.primary.withOpacity(0.12), + highlightColor: AppColors.primary.withOpacity(0.08), + dividerColor: AppColors.border, + cardColor: AppColors.card, + fontFamily: 'Noto Sans SC', + textTheme: _textTheme, + appBarTheme: const AppBarTheme( + centerTitle: false, + elevation: 0, + backgroundColor: AppColors.background, + foregroundColor: AppColors.textPrimary, + titleTextStyle: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + fontFamily: 'Noto Sans SC', + fontFamilyFallback: ['Inter'], + ), + ), + cardTheme: CardThemeData( + color: AppColors.card, + elevation: 6, + shadowColor: Colors.black.withOpacity(0.20), + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.large), + side: const BorderSide(color: AppColors.border), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.background, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.medium), + borderSide: const BorderSide(color: AppColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppRadius.medium), + borderSide: const BorderSide(color: AppColors.primary), + ), + hintStyle: const TextStyle(color: AppColors.textSecondary), + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: AppColors.card, + height: 64, + indicatorColor: AppColors.primary.withOpacity(0.18), + labelTextStyle: WidgetStateProperty.all( + const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + fontFamily: 'Inter', + fontFamilyFallback: ['Noto Sans SC'], + ), + ), + iconTheme: WidgetStateProperty.resolveWith( + (states) => IconThemeData( + size: 24, + color: states.contains(WidgetState.selected) + ? AppColors.textPrimary + : AppColors.textSecondary, + ), + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.medium), + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + fontFamily: 'Inter', + fontFamilyFallback: ['Noto Sans SC'], + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + foregroundColor: AppColors.primary, + side: const BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.medium), + ), + ), + ), + switchTheme: SwitchThemeData( + trackOutlineColor: WidgetStateProperty.all(Colors.transparent), + thumbColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? AppColors.primary + : AppColors.textSecondary, + ), + trackColor: WidgetStateProperty.resolveWith( + (states) => states.contains(WidgetState.selected) + ? AppColors.primary.withOpacity(0.4) + : AppColors.border, + ), + ), + sliderTheme: const SliderThemeData( + activeTrackColor: AppColors.primary, + inactiveTrackColor: AppColors.border, + thumbColor: AppColors.primary, + trackHeight: 4, + thumbShape: RoundSliderThumbShape(enabledThumbRadius: 10), + ), + ); + + return base.copyWith( + textSelectionTheme: const TextSelectionThemeData( + cursorColor: AppColors.primary, + selectionColor: Color(0x446366F1), + selectionHandleColor: AppColors.primary, + ), + ); + } + + static const TextTheme _textTheme = TextTheme( + headlineLarge: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + fontFamily: 'Inter', + fontFamilyFallback: ['Noto Sans SC'], + ), + headlineMedium: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + fontFamily: 'Inter', + fontFamilyFallback: ['Noto Sans SC'], + ), + headlineSmall: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + fontFamily: 'Inter', + fontFamilyFallback: ['Noto Sans SC'], + ), + bodyLarge: TextStyle( + fontSize: 16, + height: 1.5, + color: AppColors.textPrimary, + fontFamily: 'Noto Sans SC', + fontFamilyFallback: ['Inter'], + ), + bodyMedium: TextStyle( + fontSize: 14, + height: 1.4, + color: AppColors.textSecondary, + fontFamily: 'Noto Sans SC', + fontFamilyFallback: ['Inter'], + ), + bodySmall: TextStyle( + fontSize: 12, + height: 1.3, + color: AppColors.textSecondary, + fontFamily: 'Noto Sans SC', + fontFamilyFallback: ['Inter'], + ), + ); +} diff --git a/clients/flutter/lib/widgets/control_button.dart b/clients/flutter/lib/widgets/control_button.dart new file mode 100644 index 0000000..565f620 --- /dev/null +++ b/clients/flutter/lib/widgets/control_button.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; + +class ControlButton extends StatelessWidget { + const ControlButton({ + required this.label, + required this.icon, + required this.onPressed, + this.isFilled = true, + super.key, + }); + + final String label; + final IconData icon; + final VoidCallback? onPressed; + final bool isFilled; + + @override + Widget build(BuildContext context) { + if (isFilled) { + return DecoratedBox( + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(AppRadius.medium), + ), + child: FilledButton.icon( + style: FilledButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + minimumSize: const Size.fromHeight(48), + ), + onPressed: onPressed, + icon: Icon(icon), + label: Text(label), + ), + ); + } + + return OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon), + label: Text(label), + ); + } +} diff --git a/clients/flutter/lib/widgets/status_card.dart b/clients/flutter/lib/widgets/status_card.dart new file mode 100644 index 0000000..6cc72ec --- /dev/null +++ b/clients/flutter/lib/widgets/status_card.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; + +class StatusCard extends StatelessWidget { + const StatusCard({ + required this.title, + required this.value, + required this.subtitle, + required this.icon, + required this.accentColor, + super.key, + }); + + final String title; + final String value; + final String subtitle; + final IconData icon; + final Color accentColor; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: accentColor.withOpacity(0.16), + borderRadius: BorderRadius.circular(AppRadius.medium), + ), + child: Icon(icon, color: accentColor), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: AppSpacing.xs), + Text(value, style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: AppSpacing.xs), + Text(subtitle, style: Theme.of(context).textTheme.bodySmall), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/clients/flutter/lib/widgets/wifi_list_tile.dart b/clients/flutter/lib/widgets/wifi_list_tile.dart new file mode 100644 index 0000000..dcb3446 --- /dev/null +++ b/clients/flutter/lib/widgets/wifi_list_tile.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import '../models/wifi_network.dart'; +import '../theme/app_colors.dart'; + +class WifiListTile extends StatelessWidget { + const WifiListTile({ + required this.network, + required this.onTap, + super.key, + }); + + final WifiNetwork network; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Card( + child: ListTile( + onTap: onTap, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.xs, + ), + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.info.withOpacity(0.14), + borderRadius: BorderRadius.circular(AppRadius.medium), + ), + child: const Icon(Icons.wifi_rounded, color: AppColors.info), + ), + title: Text(network.ssid), + subtitle: Text('${network.security} · ${network.signalLabel}'), + trailing: const Icon(Icons.chevron_right_rounded), + ), + ); + } +} diff --git a/clients/flutter/pubspec.yaml b/clients/flutter/pubspec.yaml new file mode 100644 index 0000000..9b8b073 --- /dev/null +++ b/clients/flutter/pubspec.yaml @@ -0,0 +1,25 @@ +name: showen_v2_flutter +description: ShowenV2 cross-platform mobile controller. +publish_to: 'none' + +version: 0.1.0+1 + +environment: + sdk: '>=3.3.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + flutter_blue_plus: ^1.35.3 + go_router: ^14.8.1 + http: ^1.2.1 + provider: ^6.1.2 + web_socket_channel: ^3.0.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true diff --git a/src/core/config.rs b/src/core/config.rs index 708492d..f10c714 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -294,7 +294,7 @@ fn default_ble_enabled() -> bool { true } fn default_ble_device_name() -> String { - "showen".to_string() + "Showen".to_string() } // ── 加载与验证 ── diff --git a/src/plugins/ble/gatt.rs b/src/plugins/ble/gatt.rs index d8dfa7b..a0fb3e1 100644 --- a/src/plugins/ble/gatt.rs +++ b/src/plugins/ble/gatt.rs @@ -165,8 +165,8 @@ struct AdvertisementData { pub fn run_ble_service( device_name: String, tx: mpsc::Sender, - control_rx: Receiver, - stop: Arc, + control_rx: &Receiver, + stop: &Arc, ) -> Result<()> { let shared = SharedState::new(tx.clone()); @@ -277,6 +277,9 @@ pub fn run_ble_service( let adapter_path = find_adapter(&conn)?; configure_adapter(&conn, &adapter_path, &device_name)?; + // 先尝试清理上一次进程残留的注册(防止崩溃后 BlueZ 状态残留) + let _ = unregister_ble_objects(&conn, &adapter_path); + // 非阻塞发送 RegisterApplication + RegisterAdvertisement let _gatt_serial = send_register_gatt_app(&conn, &adapter_path)?; let _ad_serial = send_register_advertisement(&conn, &adapter_path)?; @@ -554,6 +557,20 @@ fn configure_adapter(conn: &Connection, adapter_path: &str, device_name: &str) - adapter .set(ADAPTER_IFACE, "Alias", device_name.to_string()) .context("failed to set BLE adapter alias")?; + adapter + .set(ADAPTER_IFACE, "Discoverable", true) + .context("failed to set BLE adapter discoverable")?; + // DiscoverableTimeout=0 表示永久可发现 + adapter + .set(ADAPTER_IFACE, "DiscoverableTimeout", 0u32) + .context("failed to set DiscoverableTimeout")?; + adapter + .set(ADAPTER_IFACE, "Pairable", true) + .context("failed to set BLE adapter pairable")?; + // PairableTimeout=0 表示永久可配对 + adapter + .set(ADAPTER_IFACE, "PairableTimeout", 0u32) + .context("failed to set PairableTimeout")?; Ok(()) } diff --git a/src/plugins/ble/mod.rs b/src/plugins/ble/mod.rs index 6651e44..575d2bb 100644 --- a/src/plugins/ble/mod.rs +++ b/src/plugins/ble/mod.rs @@ -82,11 +82,32 @@ impl Plugin for BlePlugin { self.control_tx = Some(control_tx); self.worker = Some(thread::spawn(move || { - let result = gatt::run_ble_service(device_name, tx, control_rx, stop); - if let Err(ref error) = result { - eprintln!("[BlePlugin] worker exited with error: {error:#}"); + // 自动重连循环:BLE 服务出错后等待 3 秒重试 + loop { + if stop.load(Ordering::SeqCst) { + return Ok(()); + } + let result = + gatt::run_ble_service(device_name.clone(), tx.clone(), &control_rx, &stop); + match result { + Ok(()) => return Ok(()), + Err(ref error) => { + if stop.load(Ordering::SeqCst) { + return Ok(()); + } + eprintln!( + "[BlePlugin] worker error: {error:#}, restarting in 3 seconds..." + ); + // 等待 3 秒,但如果 stop 被设置则立即退出 + for _ in 0..30 { + if stop.load(Ordering::SeqCst) { + return Ok(()); + } + thread::sleep(std::time::Duration::from_millis(100)); + } + } + } } - result })); Ok(()) } diff --git a/src/plugins/http/routes.rs b/src/plugins/http/routes.rs index d61b5a4..1b77621 100644 --- a/src/plugins/http/routes.rs +++ b/src/plugins/http/routes.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use std::sync::{mpsc, Arc}; use std::time::{Duration, Instant}; use warp::http::StatusCode; -use warp::multipart::{FormData, Part}; +use warp::multipart::FormData; use warp::{Filter, Reply}; #[derive(Deserialize)] @@ -44,6 +44,21 @@ struct VideoFileInfo { size: u64, } +#[derive(Serialize)] +struct FileEntry { + name: String, + is_dir: bool, + size: u64, +} + +#[derive(Deserialize)] +struct FileMoveRequest { + from_dir: String, + from_path: String, + to_dir: String, + to_path: String, +} + #[derive(Serialize)] struct WifiStatusResponse { connected: bool, @@ -81,6 +96,8 @@ pub(crate) fn build_routes( .or(config_get_route(Arc::clone(&state))) .or(config_display_route(Arc::clone(&state))) .or(config_update_route(tx.clone(), Arc::clone(&state))) + .or(config_available_route(Arc::clone(&state))) + .or(config_switch_route(tx.clone(), Arc::clone(&state))) .boxed(); let media_api = video_list_route(Arc::clone(&state)) @@ -106,14 +123,26 @@ pub(crate) fn build_routes( .or(plugin_check_updates_route(tx.clone())) .boxed(); - let api = core_api.or(media_api).or(plugin_api); + let file_api = file_list_route(Arc::clone(&state)) + .or(file_upload_route(Arc::clone(&state))) + .or(file_download_route(Arc::clone(&state))) + .or(file_delete_route(Arc::clone(&state))) + .or(file_move_route(Arc::clone(&state))) + .or(file_mkdir_route(Arc::clone(&state))) + .boxed(); - root_route().or(ws_route(tx.clone(), Arc::clone(&state))).or(api).with( - warp::cors() - .allow_any_origin() - .allow_headers(["content-type"]) - .allow_methods(["GET", "POST", "DELETE", "OPTIONS"]), - ) + let api = core_api.or(media_api).or(plugin_api).or(file_api); + + root_route() + .or(download_route(Arc::clone(&state))) + .or(ws_route(tx.clone(), Arc::clone(&state))) + .or(api) + .with( + warp::cors() + .allow_any_origin() + .allow_headers(["content-type"]) + .allow_methods(["GET", "POST", "DELETE", "OPTIONS"]), + ) } fn root_route() -> impl Filter + Clone { @@ -142,6 +171,36 @@ fn ws_route( }) } +fn download_route( + state: Arc, +) -> impl Filter + Clone { + warp::path!("download" / String) + .and(warp::path::end()) + .and(warp::get()) + .and(with_state(state)) + .and_then(|filename: String, state: Arc| async move { + let safe_name = sanitize_filename(&filename); + if safe_name.is_empty() || safe_name != filename { + return Ok::<_, Infallible>(warp::reply::with_status( + warp::reply::html("无效的文件名".to_string()), + StatusCode::BAD_REQUEST, + ) + .into_response()); + } + + let full = downloads_dir(state.config().as_ref()).join(&safe_name); + if !full.is_file() { + return Ok(warp::reply::with_status( + warp::reply::html("文件不存在".to_string()), + StatusCode::NOT_FOUND, + ) + .into_response()); + } + + Ok(read_attachment_response(&full)) + }) +} + fn status_route( state: Arc, ) -> impl Filter + Clone { @@ -335,6 +394,96 @@ fn config_update_route( .and_then(handle_config_update) } +fn config_available_route( + state: Arc, +) -> impl Filter + Clone { + warp::path!("api" / "config" / "available") + .and(warp::get()) + .and(with_state(state)) + .and_then(|state: Arc| async move { + let config = state.config(); + let configs_dir = &config.source_dir; + let mut files: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(configs_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if name.ends_with(".json") { + files.push(name); + } + } + } + files.sort(); + // 标记当前活跃的配置 + let active = config.source_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let result = serde_json::json!({ + "configs": files, + "active": active, + }); + Ok::<_, Infallible>(json_response(StatusCode::OK, &result)) + }) +} + +#[derive(Deserialize)] +struct ConfigSwitchRequest { + filename: String, +} + +fn config_switch_route( + tx: mpsc::Sender, + state: Arc, +) -> impl Filter + Clone { + warp::path!("api" / "config" / "switch") + .and(warp::post()) + .and(warp::body::json::()) + .and(with_tx(tx)) + .and(with_state(state)) + .and_then(|req: ConfigSwitchRequest, tx: mpsc::Sender, state: Arc| async move { + // 验证文件名安全 + if req.filename.contains("..") || req.filename.contains('/') || req.filename.contains('\\') { + return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "文件名不合法")); + } + if !req.filename.ends_with(".json") { + return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "只支持 .json 配置文件")); + } + + let config = state.config(); + let target_path = config.source_dir.join(&req.filename); + if !target_path.exists() { + return Ok(error_json(StatusCode::NOT_FOUND, "配置文件不存在")); + } + + // 读取目标配置文件内容 + let raw = match std::fs::read_to_string(&target_path) { + Ok(raw) => raw, + Err(e) => return Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("读取失败: {e}"))), + }; + + // 验证配置合法 + if let Err(e) = config::parse_str(&raw, target_path.clone()) { + return Ok(error_json(StatusCode::BAD_REQUEST, &format!("配置验证失败: {e}"))); + } + + // 写入当前活跃配置文件路径 + if let Err(e) = std::fs::write(&config.source_path, &raw) { + return Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("写入失败: {e}"))); + } + + // 触发热重载 + if let Err(e) = tx.send(Envelope { + from: "http".to_string(), + to: Destination::Manager, + message: Message::ConfigReloadRequest, + }) { + return Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("发送重载请求失败: {e}"))); + } + + Ok(success_json(format!("已切换到配置: {}", req.filename))) + }) +} + fn video_list_route( state: Arc, ) -> impl Filter + Clone { @@ -603,23 +752,13 @@ async fn handle_config_update( } async fn handle_video_upload( - form: FormData, + mut form: FormData, state: Arc, ) -> Result { let dir = video_dir(state.config().as_ref()); - let parts: Result, _> = form.try_collect().await; - let parts = match parts { - Ok(parts) => parts, - Err(error) => { - return Ok(error_json( - StatusCode::BAD_REQUEST, - &format!("上传失败: {error}"), - )); - } - }; - let mut uploaded = Vec::new(); - for part in parts { + + while let Ok(Some(part)) = form.try_next().await { let Some(filename) = part.filename() else { continue; }; @@ -1035,6 +1174,400 @@ where }) } +// ── 文件管理 API ── + +/// 根据 dir key 解析到实际文件系统路径。仅允许 videos/configs/plugins 三个目录。 +fn resolve_managed_dir(dir_key: &str, config: &AppConfig) -> Option { + match dir_key { + "videos" => Some(video_dir(config)), + "configs" => { + // source_dir 就是 configs/ 目录 + Some(config.source_dir.clone()) + } + "plugins" => { + // plugin_store/ 在项目根目录下(source_dir 的父目录) + let project_root = config.source_dir.parent()?; + Some(project_root.join("plugin_store")) + } + _ => None, + } +} + +/// 验证相对路径不逃逸出基目录。返回规范化后的完整路径。 +fn validate_managed_path(base: &Path, relative: &str) -> Option { + if relative.contains("..") || relative.starts_with('/') || relative.starts_with('\\') { + return None; + } + let full = base.join(relative); + if let Ok(canonical_base) = base.canonicalize() { + if full.exists() { + let canonical = full.canonicalize().ok()?; + if canonical.starts_with(&canonical_base) { + return Some(canonical); + } + } else { + // 新文件:确保父目录存在且在 base 内 + let parent = full.parent()?; + if parent.exists() { + let canonical_parent = parent.canonicalize().ok()?; + if canonical_parent.starts_with(&canonical_base) { + return Some(full); + } + } + } + } + None +} + +fn list_dir_entries(dir: &Path) -> Vec { + let mut entries = Vec::new(); + if let Ok(rd) = std::fs::read_dir(dir) { + for entry in rd.flatten() { + if let Ok(meta) = entry.metadata() { + entries.push(FileEntry { + name: entry.file_name().to_string_lossy().into_owned(), + is_dir: meta.is_dir(), + size: if meta.is_file() { meta.len() } else { 0 }, + }); + } + } + } + entries.sort_by(|a, b| { + // 目录排前面 + b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)) + }); + entries +} + +fn file_list_route( + state: Arc, +) -> impl Filter + Clone { + warp::path!("api" / "files" / String) + .and(warp::get()) + .and(warp::query::raw().or(warp::any().map(String::new)).unify()) + .and(with_state(state)) + .and_then(|dir_key: String, query: String, state: Arc| async move { + let config = state.config(); + let base = match resolve_managed_dir(&dir_key, config.as_ref()) { + Some(d) => d, + None => return Ok::<_, Infallible>(error_json( + StatusCode::BAD_REQUEST, + &format!("不支持的目录: {dir_key}(仅支持 videos/configs/plugins)"), + )), + }; + + // 支持 ?path=subdir 子目录浏览 + let sub = query + .split('&') + .find_map(|kv| { + let mut parts = kv.splitn(2, '='); + if parts.next()? == "path" { + Some( + percent_decode(parts.next().unwrap_or("")) + ) + } else { + None + } + }) + .unwrap_or_default(); + + let target = if sub.is_empty() { + base.clone() + } else { + match validate_managed_path(&base, &sub) { + Some(p) => p, + None => return Ok(error_json(StatusCode::FORBIDDEN, "路径不合法")), + } + }; + + if !target.is_dir() { + return Ok(error_json(StatusCode::NOT_FOUND, "目录不存在")); + } + + Ok(json_response(StatusCode::OK, &list_dir_entries(&target))) + }) +} + +fn file_upload_route( + state: Arc, +) -> impl Filter + Clone { + warp::path!("api" / "files" / String / "upload") + .and(warp::post()) + .and(warp::multipart::form().max_length(500 * 1024 * 1024)) + .and(warp::query::raw().or(warp::any().map(String::new)).unify()) + .and(with_state(state)) + .and_then(|dir_key: String, mut form: FormData, query: String, state: Arc| async move { + let config = state.config(); + let base = match resolve_managed_dir(&dir_key, config.as_ref()) { + Some(d) => d, + None => return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "不支持的目录")), + }; + + let sub = query + .split('&') + .find_map(|kv| { + let mut parts = kv.splitn(2, '='); + if parts.next()? == "path" { + Some(percent_decode(parts.next().unwrap_or(""))) + } else { + None + } + }) + .unwrap_or_default(); + + let target_dir = if sub.is_empty() { + base.clone() + } else { + match validate_managed_path(&base, &sub) { + Some(p) if p.is_dir() => p, + _ => return Ok(error_json(StatusCode::FORBIDDEN, "目标目录不合法")), + } + }; + + let mut uploaded = Vec::new(); + while let Ok(Some(part)) = form.try_next().await { + let Some(filename) = part.filename() else { continue }; + let safe_name = sanitize_filename(filename); + if safe_name.is_empty() { continue } + + let dest = target_dir.join(&safe_name); + if validate_managed_path(&base, &dest.strip_prefix(&base).unwrap_or(Path::new("")).to_string_lossy()).is_none() { + continue; + } + + let data = match part + .stream() + .try_fold(Vec::new(), |mut acc, buf| async move { + acc.extend_from_slice(buf.chunk()); + Ok(acc) + }) + .await + { + Ok(d) => d, + Err(e) => return Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("读取失败: {e}"))), + }; + + if let Err(e) = std::fs::write(&dest, &data) { + return Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("保存失败: {e}"))); + } + uploaded.push(safe_name); + } + + if uploaded.is_empty() { + Ok(error_json(StatusCode::BAD_REQUEST, "未找到上传文件")) + } else { + Ok(success_json(format!("已上传 {} 个文件: {}", uploaded.len(), uploaded.join(", ")))) + } + }) +} + +fn file_download_route( + state: Arc, +) -> impl Filter + Clone { + warp::path!("api" / "files" / String / "download") + .and(warp::get()) + .and(warp::query::raw().or(warp::any().map(String::new)).unify()) + .and(with_state(state)) + .and_then(|dir_key: String, query: String, state: Arc| async move { + let config = state.config(); + let base = match resolve_managed_dir(&dir_key, config.as_ref()) { + Some(d) => d, + None => return Ok::<_, Infallible>( + warp::reply::with_status( + warp::reply::html("不支持的目录".to_string()), + StatusCode::BAD_REQUEST, + ).into_response() + ), + }; + + let file_path = query + .split('&') + .find_map(|kv| { + let mut parts = kv.splitn(2, '='); + if parts.next()? == "path" { + Some(percent_decode(parts.next().unwrap_or(""))) + } else { + None + } + }) + .unwrap_or_default(); + + if file_path.is_empty() { + return Ok(warp::reply::with_status( + warp::reply::html("缺少 path 参数".to_string()), + StatusCode::BAD_REQUEST, + ).into_response()); + } + + let full = match validate_managed_path(&base, &file_path) { + Some(p) => p, + None => return Ok(warp::reply::with_status( + warp::reply::html("路径不合法".to_string()), + StatusCode::FORBIDDEN, + ).into_response()), + }; + + if !full.is_file() { + return Ok(warp::reply::with_status( + warp::reply::html("文件不存在".to_string()), + StatusCode::NOT_FOUND, + ).into_response()); + } + + Ok(read_attachment_response(&full)) + }) +} + +fn file_delete_route( + state: Arc, +) -> impl Filter + Clone { + warp::path!("api" / "files" / String / "delete") + .and(warp::post()) + .and(warp::body::json::()) + .and(with_state(state)) + .and_then(|dir_key: String, body: serde_json::Value, state: Arc| async move { + let config = state.config(); + let base = match resolve_managed_dir(&dir_key, config.as_ref()) { + Some(d) => d, + None => return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "不支持的目录")), + }; + + let path = body.get("path").and_then(|v| v.as_str()).unwrap_or(""); + if path.is_empty() { + return Ok(error_json(StatusCode::BAD_REQUEST, "缺少 path")); + } + + let full = match validate_managed_path(&base, path) { + Some(p) => p, + None => return Ok(error_json(StatusCode::FORBIDDEN, "路径不合法")), + }; + + if !full.exists() { + return Ok(error_json(StatusCode::NOT_FOUND, "文件不存在")); + } + + let result = if full.is_dir() { + std::fs::remove_dir_all(&full) + } else { + std::fs::remove_file(&full) + }; + + match result { + Ok(()) => Ok(success_json(format!("已删除: {path}"))), + Err(e) => Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("删除失败: {e}"))), + } + }) +} + +fn file_move_route( + state: Arc, +) -> impl Filter + Clone { + warp::path!("api" / "files" / "move") + .and(warp::post()) + .and(warp::body::json::()) + .and(with_state(state)) + .and_then(|req: FileMoveRequest, state: Arc| async move { + let config = state.config(); + let src_base = match resolve_managed_dir(&req.from_dir, config.as_ref()) { + Some(d) => d, + None => return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "源目录不支持")), + }; + let dst_base = match resolve_managed_dir(&req.to_dir, config.as_ref()) { + Some(d) => d, + None => return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "目标目录不支持")), + }; + + let src = match validate_managed_path(&src_base, &req.from_path) { + Some(p) => p, + None => return Ok(error_json(StatusCode::FORBIDDEN, "源路径不合法")), + }; + let dst = match validate_managed_path(&dst_base, &req.to_path) { + Some(p) => p, + None => return Ok(error_json(StatusCode::FORBIDDEN, "目标路径不合法")), + }; + + if !src.exists() { + return Ok(error_json(StatusCode::NOT_FOUND, "源文件不存在")); + } + + if dst.exists() { + return Ok(error_json(StatusCode::CONFLICT, "目标路径已存在")); + } + + // 先尝试 rename(同文件系统),失败则 copy+delete + match std::fs::rename(&src, &dst) { + Ok(()) => Ok(success_json(format!("已移动: {} → {}", req.from_path, req.to_path))), + Err(_) => { + // 跨文件系统:copy then delete + match std::fs::copy(&src, &dst) { + Ok(_) => { + let _ = std::fs::remove_file(&src); + Ok(success_json(format!("已移动: {} → {}", req.from_path, req.to_path))) + } + Err(e) => Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("移动失败: {e}"))), + } + } + } + }) +} + +fn file_mkdir_route( + state: Arc, +) -> impl Filter + Clone { + warp::path!("api" / "files" / String / "mkdir") + .and(warp::post()) + .and(warp::body::json::()) + .and(with_state(state)) + .and_then(|dir_key: String, body: serde_json::Value, state: Arc| async move { + let config = state.config(); + let base = match resolve_managed_dir(&dir_key, config.as_ref()) { + Some(d) => d, + None => return Ok::<_, Infallible>(error_json(StatusCode::BAD_REQUEST, "不支持的目录")), + }; + + let path = body.get("path").and_then(|v| v.as_str()).unwrap_or(""); + if path.is_empty() { + return Ok(error_json(StatusCode::BAD_REQUEST, "缺少 path")); + } + + let full = match validate_managed_path(&base, path) { + Some(p) => p, + None => return Ok(error_json(StatusCode::FORBIDDEN, "路径不合法")), + }; + + match std::fs::create_dir_all(&full) { + Ok(()) => Ok(success_json(format!("已创建目录: {path}"))), + Err(e) => Ok(error_json(StatusCode::INTERNAL_SERVER_ERROR, &format!("创建失败: {e}"))), + } + }) +} + +fn percent_decode(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.bytes(); + while let Some(b) = chars.next() { + if b == b'%' { + let h = chars.next().unwrap_or(0); + let l = chars.next().unwrap_or(0); + let hex = [h, l]; + if let Ok(s) = std::str::from_utf8(&hex) { + if let Ok(val) = u8::from_str_radix(s, 16) { + result.push(val as char); + continue; + } + } + result.push('%'); + result.push(h as char); + result.push(l as char); + } else if b == b'+' { + result.push(' '); + } else { + result.push(b as char); + } + } + result +} + fn list_video_files(dir: &Path) -> Vec { let mut files = Vec::new(); @@ -1090,6 +1623,39 @@ fn video_dir(config: &AppConfig) -> PathBuf { config.source_dir.clone() } +fn downloads_dir(config: &AppConfig) -> PathBuf { + config.source_dir.join("downloads") +} + +fn read_attachment_response(path: &Path) -> warp::reply::Response { + match std::fs::read(path) { + Ok(data) => { + let filename = path.file_name().unwrap_or_default().to_string_lossy(); + match warp::http::Response::builder() + .header("Content-Type", "application/octet-stream") + .header( + "Content-Disposition", + format!("attachment; filename=\"{filename}\""), + ) + .header("Content-Length", data.len().to_string()) + .body(data) + { + Ok(response) => response.into_response(), + Err(_) => warp::reply::with_status( + warp::reply::html("响应构建失败".to_string()), + StatusCode::INTERNAL_SERVER_ERROR, + ) + .into_response(), + } + } + Err(e) => warp::reply::with_status( + warp::reply::html(format!("读取失败: {e}")), + StatusCode::INTERNAL_SERVER_ERROR, + ) + .into_response(), + } +} + fn strip_cidr(value: &str) -> String { value.split('/').next().unwrap_or_default().to_string() } @@ -1305,92 +1871,389 @@ fn json_response(status: StatusCode, payload: &T) -> warp::reply:: warp::reply::with_status(warp::reply::json(payload), status).into_response() } -const WEB_UI_HTML: &str = r#" +const WEB_UI_HTML: &str = r##" Showen 控制台
-
-
-

Showen 远程控制台

-

Warp Web UI + HTTP API,覆盖旧 `api_server.rs` 的控制、配置、视频、WiFi 与 BLE 端点。

-
+ +
+
+

Showen 控制台

+
+ + WS 连接中... +
+
+ - +
+
-

播放状态

状态--
当前视频--
索引--
列表长度--
-

播放控制

-

触发器

+
+

播放状态

+
+
状态--
+
当前视频--
+
索引--
+
列表长度--
+
+
+
+

播放操作

+
+ + + + +
+
+
+
+
+
+
+

触发器

+
+ + + + +
+
+
+
+
+
+
-

上传视频

设备文件

加载中...
+ +
+
+
+

上传视频

+ +
+
+
+

设备视频文件

+
加载中...
+
+
+
+
-

网络状态

连接--
SSID--
IP--
BLE--

扫描 WiFi

点击扫描按钮搜索附近网络

连接 WiFi

热点与 BLE

+ +
+
+

文件管理器

+
+ + + + + +
+
+
选择目录后加载...
+
+ +
+
+
-

显示设置

配置编辑器

+ +
+
+
+

网络状态

+
+
WiFi--
+
SSID--
+
IP--
+
BLE--
+
+
+
+
+

WiFi 扫描

+
点击扫描搜索附近网络
+
+
+
+

连接 WiFi

+ + +
+
+
+

热点 / BLE

+ + +
+ + +
+ +
+ + +
+
+
+
+ + +
+
+
+

配置文件切换

+
+
+
+
+
+
+

显示设置

+
+
+
+
+

配置编辑器

+ +
+ + + +
+
+
+
-"#; +"##;