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; /// 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]. Future connect() async { _set(RemoteConnStatus.connecting); try { final ws = await WebSocket.connect(device.wsUri.toString()) .timeout(const Duration(seconds: 8)); _socket = ws; _sub = ws.listen( _onData, onDone: () => _set(RemoteConnStatus.disconnected), onError: (_) => _set(RemoteConnStatus.error), cancelOnError: true, ); } catch (_) { _set(RemoteConnStatus.error); } } /// Send a control command to the host (no-op if not connected). void send(RemoteCommand cmd) => _sendMessage(CommandMessage(cmd)); Future close() async { 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; _set(RemoteConnStatus.connected); } else if (msg is DenyMessage) { _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); } }