import 'dart:async'; import 'dart:io'; import 'discovery.dart'; import 'messages.dart'; /// Connection lifecycle of a remote-control session. enum RemoteConnStatus { /// Dialing / mid-handshake. connecting, /// Handshake accepted; snapshots flowing. connected, /// Host rejected the handshake (wrong server / version / auth). denied, /// Cleanly closed (host went away or we detached). disconnected, /// Transport failure (couldn't reach the host, socket dropped). error, } /// The controller side of a session: dials a [DiscoveredDevice], completes the /// HMAC handshake, then surfaces the host's [StateSnapshot]s and forwards /// [RemoteCommand]s back. Pure transport — it holds no playback state and knows /// nothing about just_audio; `RemoteControlController` adapts it to the UI. class RemoteSession { RemoteSession({ required this.device, required this.serverKey, required this.password, required this.deviceName, }); final DiscoveredDevice device; final String serverKey; final String password; final String deviceName; WebSocket? _socket; StreamSubscription? _sub; final StreamController _snapshots = StreamController.broadcast(); final StreamController _status = StreamController.broadcast(); RemoteConnStatus _current = RemoteConnStatus.disconnected; /// True once [close] (or a hard denial / cold-connect give-up) has run — /// stops all further reconnection. bool _closed = false; /// A dial is in flight; guards against overlapping [_dial] calls (e.g. a /// backoff timer firing while [reconnectNow] also dials). bool _dialing = false; /// True once we've completed a handshake at least once. Distinguishes a /// device that never answers (stale in the list — give up) from an /// established session that later dropped (lock / Wi-Fi blip — persist). bool _everConnected = false; /// Consecutive failed dials since the last successful connection; indexes /// [_kBackoff]. int _attempt = 0; Timer? _retryTimer; static const Duration _kDialTimeout = Duration(seconds: 8); /// Keepalive interval. Periodic pings hold the socket open through brief /// idle/doze windows and surface a dead peer promptly instead of leaving a /// silently half-open connection. static const Duration _kPingInterval = Duration(seconds: 5); /// Reconnect backoff in seconds, held at the last value once reached. static const List _kBackoff = [0, 1, 2, 4, 8, 15]; /// How many times to retry before the *first* successful handshake before /// giving up (a device that never answers is probably gone). static const int _kMaxColdAttempts = 3; /// Host-reported friendly name, available after [RemoteConnStatus.connected]. String? hostDevice; Stream get snapshots => _snapshots.stream; Stream get status => _status.stream; RemoteConnStatus get currentStatus => _current; /// Dial the host and start the handshake. Status transitions are emitted on /// [status]; on success snapshots begin arriving on [snapshots]. The session /// then self-heals: a dropped socket is retried with backoff (see /// [_scheduleRetry]) rather than surfacing a terminal status, so control /// persists across a phone lock or Wi-Fi blip (bug-fixes #5). Future connect() async { _closed = false; _attempt = 0; await _dial(); } Future _dial() async { if (_closed || _dialing) return; _dialing = true; _set(RemoteConnStatus.connecting); try { final ws = await WebSocket.connect(device.wsUri.toString()) .timeout(_kDialTimeout); if (_closed) { unawaited(ws.close().catchError((_) {})); return; } ws.pingInterval = _kPingInterval; _socket = ws; _sub = ws.listen( _onData, onDone: _onDropped, onError: (_) => _onDropped(), cancelOnError: true, ); } catch (_) { _onDropped(); } finally { _dialing = false; } } /// The socket went away (drop, dial failure, or refused). Unless we've been /// [close]d, keep the session logically alive and retry. void _onDropped() { _sub = null; _socket = null; if (_closed) return; _scheduleRetry(); } void _scheduleRetry() { // Give up only if we never got a handshake in the first place — an // established session that dropped is retried indefinitely. if (!_everConnected && _attempt >= _kMaxColdAttempts) { _closed = true; _set(RemoteConnStatus.error); return; } _retryTimer?.cancel(); final i = _attempt < _kBackoff.length ? _attempt : _kBackoff.length - 1; _attempt++; _set(RemoteConnStatus.connecting); _retryTimer = Timer(Duration(seconds: _kBackoff[i]), () { _retryTimer = null; unawaited(_dial()); }); } /// Reset the backoff and re-dial immediately. Called when the app returns to /// the foreground: a controller is suspended while the phone is locked, so /// its socket to the host is usually dead on resume and we don't want to make /// the user wait out the backoff before control is restored (bug-fixes #5). void reconnectNow() { if (_closed) return; if (_current == RemoteConnStatus.connected && _socket != null) return; _attempt = 0; _retryTimer?.cancel(); _retryTimer = null; unawaited(_dial()); } /// Send a control command to the host (no-op if not connected). void send(RemoteCommand cmd) => _sendMessage(CommandMessage(cmd)); Future close() async { _closed = true; _retryTimer?.cancel(); _retryTimer = null; await _sub?.cancel(); _sub = null; try { await _socket?.close(); } catch (_) { // Already closed. } _socket = null; _set(RemoteConnStatus.disconnected); if (!_snapshots.isClosed) await _snapshots.close(); if (!_status.isClosed) await _status.close(); } void _onData(dynamic data) { if (data is! String) return; RemoteMessage msg; try { msg = RemoteMessage.decode(data); } catch (_) { return; // ignore a malformed frame } if (msg is ChallengeMessage) { // Prove we know the shared password without ever sending it. _sendMessage(AuthMessage( version: kRemoteProtocolVersion, serverKey: serverKey, proof: RemoteAuth.proofFor(password: password, nonce: msg.nonce), device: deviceName, )); } else if (msg is WelcomeMessage) { hostDevice = msg.device; _everConnected = true; _attempt = 0; // fresh backoff budget after a good connection _set(RemoteConnStatus.connected); } else if (msg is DenyMessage) { // A rejection (wrong account / protocol) won't fix itself — go terminal // and stop retrying. _closed = true; _retryTimer?.cancel(); _retryTimer = null; _set(RemoteConnStatus.denied); _socket?.close().catchError((_) {}); } else if (msg is SnapshotMessage) { if (!_snapshots.isClosed) _snapshots.add(msg.snapshot); } // AuthMessage / CommandMessage are host-inbound only; ignore if echoed. } void _sendMessage(RemoteMessage m) { try { _socket?.add(m.encode()); } catch (_) { // Socket gone; the listen callbacks will surface the disconnect. } } void _set(RemoteConnStatus s) { if (_current == s) return; _current = s; if (!_status.isClosed) _status.add(s); } }