235 lines
7.4 KiB
Dart
235 lines
7.4 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;
|
|
|
|
/// 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<int> _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<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]. 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<void> connect() async {
|
|
_closed = false;
|
|
_attempt = 0;
|
|
await _dial();
|
|
}
|
|
|
|
Future<void> _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<void> 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);
|
|
}
|
|
}
|