Files
ShowenV2/clients/flutter/lib/services/web_socket_service.dart
showen bff9ec535d 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>
2026-03-14 02:09:52 +08:00

173 lines
5.4 KiB
Dart

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<dynamic>? _subscription;
Timer? _reconnectTimer;
String? _deviceIp;
bool _manualDisconnect = false;
SocketConnectionStatus _connectionStatus = SocketConnectionStatus.disconnected;
final StreamController<AppEvent> _eventController =
StreamController<AppEvent>.broadcast();
final StreamController<Map<String, dynamic>> _statusController =
StreamController<Map<String, dynamic>>.broadcast();
final StreamController<Map<String, dynamic>> _stateController =
StreamController<Map<String, dynamic>>.broadcast();
final StreamController<Map<String, dynamic>> _configController =
StreamController<Map<String, dynamic>>.broadcast();
final StreamController<Map<String, dynamic>> _wifiController =
StreamController<Map<String, dynamic>>.broadcast();
final StreamController<Map<String, dynamic>> _bleController =
StreamController<Map<String, dynamic>>.broadcast();
final StreamController<SocketConnectionStatus> _connectionController =
StreamController<SocketConnectionStatus>.broadcast();
Stream<AppEvent> get events => _eventController.stream;
Stream<Map<String, dynamic>> get onStatusUpdate => _statusController.stream;
Stream<Map<String, dynamic>> get onStateUpdate => _stateController.stream;
Stream<Map<String, dynamic>> get onConfigUpdate => _configController.stream;
Stream<Map<String, dynamic>> get onWifiUpdate => _wifiController.stream;
Stream<Map<String, dynamic>> get onBleUpdate => _bleController.stream;
Stream<SocketConnectionStatus> get onConnectionChanged =>
_connectionController.stream;
SocketConnectionStatus get connectionStatus => _connectionStatus;
bool get isConnected => _connectionStatus == SocketConnectionStatus.connected;
Future<void> 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<String, dynamic> command) {
if (!isConnected || _channel == null) {
throw StateError('WebSocket 未连接');
}
_channel!.sink.add(jsonEncode(command));
}
Future<void> reconnect() async {
final deviceIp = _deviceIp;
if (deviceIp == null || deviceIp.isEmpty || _manualDisconnect) {
return;
}
_reconnectTimer?.cancel();
_reconnectTimer = Timer(const Duration(seconds: 2), () {
unawaited(connect(deviceIp));
});
}
Future<void> disconnect() async {
_manualDisconnect = true;
_reconnectTimer?.cancel();
await _subscription?.cancel();
_subscription = null;
await _channel?.sink.close();
_channel = null;
_setConnectionStatus(SocketConnectionStatus.disconnected);
}
Future<void> 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<int>);
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return;
}
final event = AppEvent.fromJson(Map<String, dynamic>.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;
}
}