network addition

This commit is contained in:
Forrest 2026-08-04 16:39:18 -04:00
parent 3bd713d667
commit 2099d3d64d
27 changed files with 2237 additions and 40 deletions

View file

@ -0,0 +1,135 @@
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<dynamic>? _sub;
final StreamController<StateSnapshot> _snapshots =
StreamController<StateSnapshot>.broadcast();
final StreamController<RemoteConnStatus> _status =
StreamController<RemoteConnStatus>.broadcast();
RemoteConnStatus _current = RemoteConnStatus.disconnected;
/// Host-reported friendly name, available after [RemoteConnStatus.connected].
String? hostDevice;
Stream<StateSnapshot> get snapshots => _snapshots.stream;
Stream<RemoteConnStatus> 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<void> 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<void> 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);
}
}