feat: Flutter 客户端 App + Web UI APK 下载入口

- 新增 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 <noreply@anthropic.com>
This commit is contained in:
showen
2026-03-14 02:09:52 +08:00
parent d4f0eb7eca
commit bff9ec535d
45 changed files with 5903 additions and 75 deletions

View File

@@ -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<List<BleDevice>>? _scanSubscription;
StreamSubscription<BleStatus>? _statusSubscription;
List<BleDevice> _devices = const <BleDevice>[];
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<BleDevice> 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<void> startScan() async {
_errorMessage = null;
_selectedDevice = null;
_isConnected = false;
_provisioningState = ProvisioningState.scanning;
_isScanning = true;
_notifySafely();
await _scanSubscription?.cancel();
_scanSubscription = _bleService
.scanForShowenDevices()
.listen((List<BleDevice> scannedDevices) {
_devices = scannedDevices;
_notifySafely();
}, onError: (Object error, StackTrace stackTrace) {
_errorMessage = error.toString();
_isScanning = false;
_provisioningState = ProvisioningState.failed;
_notifySafely();
});
Future<void>.delayed(const Duration(seconds: 6), () {
if (_isScanning) {
_isScanning = false;
_notifySafely();
}
});
}
Future<void> 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<void> provisionWifi(String ssid, String password) async {
_errorMessage = null;
_latestStatus = null;
_isProvisioning = true;
_provisioningState = ProvisioningState.writingCredentials;
_notifySafely();
try {
final Future<BleStatus> operation = _bleService.provisionWifi(
ssid,
password,
timeout: const Duration(seconds: 30),
);
Future<void>.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<void> 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<void> retryScan() async {
await disconnect();
_devices = const <BleDevice>[];
_latestStatus = null;
_errorMessage = null;
_provisioningState = ProvisioningState.scanning;
_notifySafely();
await startScan();
}
Future<void> _subscribeToStatus() async {
await _statusSubscription?.cancel();
final Stream<BleStatus> 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();
}
}

View File

@@ -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<SocketConnectionStatus> _connectionSubscription;
late final StreamSubscription<Map<String, dynamic>> _statusSubscription;
late final StreamSubscription<Map<String, dynamic>> _wifiSubscription;
late final StreamSubscription<Map<String, dynamic>> _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<void> initialize() async {
await refresh();
await connect();
}
Future<void> refresh() async {
_setLoading(true);
try {
final results = await Future.wait<dynamic>([
_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<void> loadDeviceOverview() => refresh();
Future<void> connect() async {
try {
await _webSocketService.connect(_deviceIp);
_webSocketConnected = _webSocketService.isConnected;
_errorMessage = null;
notifyListeners();
} catch (error) {
_webSocketConnected = false;
_errorMessage = error.toString();
notifyListeners();
}
}
Future<void> 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<void> startBle({String? deviceName}) async {
_setLoading(true);
try {
await _httpApiService.startBle(deviceName);
await refresh();
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
} finally {
_setLoading(false);
}
}
Future<void> 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<String, dynamic> payload) {
final playerStatus = PlayerStatus.fromJson(payload);
_status = _buildStatus(
playerStatus: playerStatus,
wifiStatus: _status.wifiStatus ?? WifiStatus.disconnected(),
bleStatus: _status.bleStatus ?? BleServiceStatus.initial(),
);
notifyListeners();
}
void _handleWifiUpdate(Map<String, dynamic> payload) {
final wifiStatus = WifiStatus.fromJson(payload);
_status = _buildStatus(
playerStatus: _status.playerStatus ?? PlayerStatus.initial(),
wifiStatus: wifiStatus,
bleStatus: _status.bleStatus ?? BleServiceStatus.initial(),
);
notifyListeners();
}
void _handleBleUpdate(Map<String, dynamic> payload) {
final normalized = <String, dynamic>{
'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();
}
}

View File

@@ -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<Map<String, dynamic>> _statusSubscription;
late final StreamSubscription<Map<String, dynamic>> _stateSubscription;
late final StreamSubscription<Map<String, dynamic>> _configSubscription;
PlayerStatus _status = PlayerStatus.initial();
List<String> _playlist = const <String>[];
List<String> _sceneOptions = const <String>['idle', 'intro', 'loop'];
bool _isLoading = false;
String? _errorMessage;
String? _currentState;
PlayerStatus get status => _status;
List<String> get playlist => _playlist;
List<String> get sceneOptions => _sceneOptions;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
String get currentState => _currentState ?? _status.currentVideo ?? 'idle';
Future<void> bootstrap() async {
_setLoading(true);
try {
final results = await Future.wait<dynamic>([
_httpApiService.getPlaybackStatus(),
_httpApiService.getPlaylist(),
_httpApiService.getConfig(),
]);
_status = results[0] as PlayerStatus;
_playlist = results[1] as List<String>;
_updateSceneOptions(results[2] as Map<String, dynamic>);
_errorMessage = null;
} catch (error) {
_errorMessage = error.toString();
} finally {
_setLoading(false);
}
}
Future<void> fetchStatus() async {
try {
_status = await _httpApiService.getPlaybackStatus();
_errorMessage = null;
notifyListeners();
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
}
}
Future<void> fetchPlaylist() async {
try {
_playlist = await _httpApiService.getPlaylist();
_errorMessage = null;
notifyListeners();
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
}
}
Future<void> play() => _runCommand(_httpApiService.play);
Future<void> pause() => _runCommand(_httpApiService.pause);
Future<void> next() => _runCommand(_httpApiService.next);
Future<void> previous() => _runCommand(_httpApiService.previous);
Future<void> gotoIndex(int index) async {
await _runCommand(() => _httpApiService.goto(index));
}
Future<void> switchScene(String name) async {
await _runCommand(() => _httpApiService.changeScene(name));
_currentState = name;
notifyListeners();
}
Future<void> triggerEvent(String name, {String? value}) {
return _runCommand(() => _httpApiService.trigger(name, value));
}
Future<void> togglePlayPause() async {
if (_status.running && !_status.paused) {
await pause();
return;
}
await play();
}
Future<void> _runCommand(Future<dynamic> Function() action) async {
_setLoading(true);
try {
await action();
await Future.wait<void>([
fetchStatus(),
fetchPlaylist(),
]);
_errorMessage = null;
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
} finally {
_setLoading(false);
}
}
void _updateSceneOptions(Map<String, dynamic> config) {
final candidates = <String>{};
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();
}
}

View File

@@ -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<Map<String, dynamic>> _wifiSubscription;
WifiStatus _status = WifiStatus.disconnected();
List<WifiNetwork> _networks = const <WifiNetwork>[];
bool _isLoading = false;
String? _errorMessage;
bool _hotspotEnabled = false;
WifiStatus get status => _status;
List<WifiNetwork> get networks => _networks;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
bool get hotspotEnabled => _hotspotEnabled;
Future<void> bootstrap() async {
_setLoading(true);
try {
final results = await Future.wait<dynamic>([
_httpApiService.getWifiStatus(),
_httpApiService.scanWifi(),
]);
_status = results[0] as WifiStatus;
_networks = results[1] as List<WifiNetwork>;
_errorMessage = null;
} catch (error) {
_errorMessage = error.toString();
} finally {
_setLoading(false);
}
}
Future<void> refreshStatus() async {
try {
_status = await _httpApiService.getWifiStatus();
notifyListeners();
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
}
}
Future<void> scanNetworks() async {
_setLoading(true);
try {
_networks = await _httpApiService.scanWifi();
_errorMessage = null;
} catch (error) {
_errorMessage = error.toString();
} finally {
_setLoading(false);
}
}
Future<void> 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<void> 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<void> 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();
}
}