mobile-music/lib/remote/remote_session.dart
2026-08-04 19:29:58 -04:00

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);
}
}