import 'dart:async'; import 'dart:convert'; import 'package:flutter/widgets.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../models/server_config.dart'; import 'wol_service.dart'; enum ConnectionStatus { disconnected, connecting, connected, reconnecting, } /// WebSocket client with dual-URL fallback, heartbeat, and auto-reconnect. class WebSocketService with WidgetsBindingObserver { WebSocketService({required this.config}); ServerConfig config; WebSocketChannel? _channel; ConnectionStatus _status = ConnectionStatus.disconnected; Timer? _heartbeatTimer; Timer? _zombieTimer; Timer? _reconnectTimer; int _reconnectAttempt = 0; bool _intentionalClose = false; DateTime? _lastPong; StreamSubscription? _subscription; // Callbacks void Function()? onOpen; void Function()? onClose; void Function()? onReconnecting; void Function(Map message)? onMessage; void Function(String error)? onError; void Function(ConnectionStatus status)? onStatusChanged; ConnectionStatus get status => _status; bool get isConnected => _status == ConnectionStatus.connected; void _setStatus(ConnectionStatus newStatus) { if (_status == newStatus) return; _status = newStatus; onStatusChanged?.call(newStatus); } /// Connect to the WebSocket server. /// Tries local URL first (2.5s timeout), then remote URL. Future connect() async { if (_status == ConnectionStatus.connected || _status == ConnectionStatus.connecting) { return; } _intentionalClose = false; _setStatus(ConnectionStatus.connecting); // Send Wake-on-LAN if MAC configured if (config.macAddress != null && config.macAddress!.isNotEmpty) { try { await WolService.wake(config.macAddress!, localHost: config.localHost); } catch (_) {} } final urls = config.urls; for (final url in urls) { if (_intentionalClose) return; try { final connected = await _tryConnect(url, timeout: url == urls.first && urls.length > 1 ? const Duration(milliseconds: 2500) : const Duration(seconds: 5)); if (connected) return; } catch (_) { continue; } } // All URLs failed _setStatus(ConnectionStatus.disconnected); onError?.call('Failed to connect to server'); _scheduleReconnect(); } Future _tryConnect(String url, {Duration? timeout}) async { try { final uri = Uri.parse(url); final channel = WebSocketChannel.connect(uri); // Wait for connection with timeout await channel.ready.timeout( timeout ?? const Duration(seconds: 5), onTimeout: () { channel.sink.close(); throw TimeoutException('Connection timeout'); }, ); _channel = channel; _reconnectAttempt = 0; _setStatus(ConnectionStatus.connected); _startHeartbeat(); _listenMessages(); onOpen?.call(); return true; } catch (e) { return false; } } void _listenMessages() { _subscription?.cancel(); _subscription = _channel?.stream.listen( (data) { _lastPong = DateTime.now(); if (data is String) { // Handle pong if (data == 'pong') return; try { final json = jsonDecode(data) as Map; onMessage?.call(json); } catch (_) { // Non-JSON message, ignore } } }, onError: (error) { onError?.call(error.toString()); _handleDisconnect(); }, onDone: () { _handleDisconnect(); }, ); } void _startHeartbeat() { _heartbeatTimer?.cancel(); _zombieTimer?.cancel(); _lastPong = DateTime.now(); // Send ping every 30 seconds _heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) { if (_channel != null && _status == ConnectionStatus.connected) { try { _channel!.sink.add(jsonEncode({'type': 'ping'})); } catch (_) { _handleDisconnect(); } } }); // Check for zombie connection every 15 seconds _zombieTimer = Timer.periodic(const Duration(seconds: 15), (_) { if (_lastPong != null) { final elapsed = DateTime.now().difference(_lastPong!); if (elapsed.inSeconds > 60) { _handleDisconnect(); } } }); } void _handleDisconnect() { _stopHeartbeat(); _subscription?.cancel(); final wasConnected = _status == ConnectionStatus.connected; try { _channel?.sink.close(); } catch (_) {} _channel = null; if (_intentionalClose) { _setStatus(ConnectionStatus.disconnected); onClose?.call(); } else if (wasConnected) { _setStatus(ConnectionStatus.reconnecting); onReconnecting?.call(); _scheduleReconnect(); } } void _stopHeartbeat() { _heartbeatTimer?.cancel(); _zombieTimer?.cancel(); _heartbeatTimer = null; _zombieTimer = null; } void _scheduleReconnect() { if (_intentionalClose) return; _reconnectTimer?.cancel(); // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max final delay = Duration( milliseconds: (1000 * (1 << _reconnectAttempt.clamp(0, 4))) .clamp(1000, 30000), ); _reconnectAttempt++; _reconnectTimer = Timer(delay, () { if (!_intentionalClose) { _setStatus(ConnectionStatus.reconnecting); onReconnecting?.call(); connect(); } }); } /// Send a JSON message. void send(Map message) { if (_channel == null || _status != ConnectionStatus.connected) { onError?.call('Not connected'); return; } try { _channel!.sink.add(jsonEncode(message)); } catch (e) { onError?.call('Send failed: $e'); } } /// Send a raw string. void sendRaw(String data) { if (_channel == null || _status != ConnectionStatus.connected) return; try { _channel!.sink.add(data); } catch (_) {} } /// Disconnect intentionally. void disconnect() { _intentionalClose = true; _reconnectTimer?.cancel(); _stopHeartbeat(); _subscription?.cancel(); try { _channel?.sink.close(); } catch (_) {} _channel = null; _setStatus(ConnectionStatus.disconnected); onClose?.call(); } /// Update config and reconnect. Future updateConfig(ServerConfig newConfig) async { config = newConfig; disconnect(); await Future.delayed(const Duration(milliseconds: 100)); await connect(); } /// Dispose all resources. void dispose() { disconnect(); _reconnectTimer?.cancel(); } // App lifecycle integration @override void didChangeAppLifecycleState(AppLifecycleState state) { switch (state) { case AppLifecycleState.resumed: if (_status != ConnectionStatus.connected && !_intentionalClose) { _reconnectAttempt = 0; connect(); } case AppLifecycleState.paused: // Keep connection alive but don't reconnect aggressively break; default: break; } } }