135 lines
3.9 KiB
Dart
135 lines
3.9 KiB
Dart
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);
|
|
}
|
|
}
|