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,41 @@
import '../playback/playback_engine.dart';
import 'messages.dart';
/// Apply a wire [RemoteCommand] to a [PlaybackCommands] target — the single
/// place the protocol's verbs map onto the playback surface. The host uses it
/// to drive its local engine from a remote's commands; keeping it transport-
/// free (no sockets) lets the loopback test exercise the mapping directly.
Future<void> applyRemoteCommand(
PlaybackCommands target,
RemoteCommand cmd,
) async {
switch (cmd.op) {
case RemoteOp.playPause:
await target.togglePlayPause();
case RemoteOp.next:
await target.next();
case RemoteOp.previous:
await target.previous();
case RemoteOp.seek:
await target.seek(Duration(milliseconds: cmd.positionMs ?? 0));
case RemoteOp.jumpTo:
await target.jumpTo(cmd.index ?? 0);
case RemoteOp.playSongs:
await target.playSongs(cmd.songs ?? const [],
startIndex: cmd.startIndex ?? 0);
case RemoteOp.playNext:
if (cmd.song != null) await target.playNext(cmd.song!);
case RemoteOp.addToQueue:
if (cmd.song != null) await target.addToQueue(cmd.song!);
case RemoteOp.removeAt:
await target.removeAt(cmd.index ?? 0);
case RemoteOp.reorder:
await target.reorderQueue(cmd.oldIndex ?? 0, cmd.newIndex ?? 0);
case RemoteOp.toggleShuffle:
await target.toggleShuffle();
case RemoteOp.cycleLoop:
await target.cycleLoop();
case RemoteOp.retry:
await target.retry();
}
}

200
lib/remote/discovery.dart Normal file
View file

@ -0,0 +1,200 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:nsd/nsd.dart';
import 'messages.dart';
/// A host advertising itself on the LAN, resolved enough to connect to.
class DiscoveredDevice {
const DiscoveredDevice({
required this.name,
required this.host,
required this.port,
required this.serverKey,
required this.version,
});
final String name;
final String host;
final int port;
/// The host's Subsonic [serverKey]; only matches are shown/controllable.
final String serverKey;
final int version;
/// `ws://host:port` — the WebSocket endpoint `remote_session.dart` dials.
Uri get wsUri => Uri(scheme: 'ws', host: host, port: port);
/// Stable identity for list diffing / selection (mDNS instance names are
/// unique per network).
String get id => name;
}
/// Wraps `nsd` mDNS registration (advertise) + discovery (browse) behind a
/// small, app-shaped API. One instance owns at most one active registration
/// and one active browse at a time.
///
/// Advertising and browsing are independent: a device that is playing
/// advertises so others can find it, and any device can browse to control one.
/// TXT records carry the [serverKey] and protocol version so the browser can
/// filter to same-server, same-version peers before ever opening a socket.
class RemoteDiscovery {
static const _kServerKey = 'sk';
static const _kVersion = 'v';
Registration? _registration;
Discovery? _discovery;
ServiceListener? _listener;
/// The final (possibly conflict-renamed) name of our own advertisement, so
/// the browser can filter it out of its own results.
String? _ownName;
String? _browseServerKey;
final StreamController<List<DiscoveredDevice>> _devices =
StreamController<List<DiscoveredDevice>>.broadcast();
List<DiscoveredDevice> _current = const [];
/// Latest discovered, filtered device list (also replayed as a stream).
List<DiscoveredDevice> get current => _current;
Stream<List<DiscoveredDevice>> get devices => _devices.stream;
bool get isAdvertising => _registration != null;
bool get isBrowsing => _discovery != null;
// ---- Advertise --------------------------------------------------------
/// Publish this device as a controllable player on [port]. Replaces any
/// existing advertisement.
Future<void> advertise({
required String deviceName,
required int port,
required String serverKey,
}) async {
await stopAdvertising();
final reg = await register(Service(
name: deviceName,
type: kRemoteServiceType,
port: port,
txt: {
_kServerKey: _bytes(serverKey),
_kVersion: _bytes('$kRemoteProtocolVersion'),
},
));
_registration = reg;
_ownName = reg.service.name;
}
Future<void> stopAdvertising() async {
final reg = _registration;
_registration = null;
_ownName = null;
if (reg != null) {
try {
await unregister(reg);
} catch (_) {
// Already gone / platform hiccup — nothing to recover.
}
}
}
// ---- Browse -----------------------------------------------------------
/// Start browsing for players on [serverKey]'s network. Emits filtered
/// [DiscoveredDevice] lists on [devices]. Replaces any existing browse.
Future<void> startBrowsing(String serverKey) async {
await stopBrowsing();
_browseServerKey = serverKey;
// v4 lookup so we get a routable address to dial rather than a bare
// `.local` hostname the socket layer may not resolve.
final discovery = await startDiscovery(
kRemoteServiceType,
ipLookupType: IpLookupType.v4,
);
_discovery = discovery;
void listener(Service service, ServiceStatus status) => _rebuild();
_listener = listener;
discovery.addServiceListener(listener);
_rebuild();
}
Future<void> stopBrowsing() async {
final discovery = _discovery;
final listener = _listener;
_discovery = null;
_listener = null;
_browseServerKey = null;
_current = const [];
if (discovery != null) {
if (listener != null) discovery.removeServiceListener(listener);
try {
await stopDiscovery(discovery);
} catch (_) {
// Already stopped.
}
}
}
Future<void> dispose() async {
await stopAdvertising();
await stopBrowsing();
await _devices.close();
}
void _rebuild() {
final discovery = _discovery;
if (discovery == null) return;
final out = <DiscoveredDevice>[];
for (final s in discovery.services) {
final dev = _toDevice(s);
if (dev == null) continue;
if (_browseServerKey != null && dev.serverKey != _browseServerKey) {
continue; // different Subsonic server — its song ids won't resolve here
}
if (dev.version != kRemoteProtocolVersion) continue;
if (_ownName != null && s.name == _ownName) continue; // ourselves
out.add(dev);
}
_current = out;
if (!_devices.isClosed) _devices.add(out);
}
DiscoveredDevice? _toDevice(Service s) {
final port = s.port;
if (port == null) return null;
final host = _hostOf(s);
if (host == null) return null;
final txt = s.txt ?? const <String, Uint8List?>{};
return DiscoveredDevice(
name: s.name ?? host,
host: host,
port: port,
serverKey: _text(txt, _kServerKey) ?? '',
version: int.tryParse(_text(txt, _kVersion) ?? '') ?? 0,
);
}
/// Prefer a resolved IPv4 address; fall back to the reported hostname.
static String? _hostOf(Service s) {
final addrs = s.addresses;
if (addrs != null && addrs.isNotEmpty) {
final v4 = addrs.firstWhere(
(a) => a.type == InternetAddressType.IPv4,
orElse: () => addrs.first,
);
return v4.address;
}
return s.host;
}
static Uint8List _bytes(String s) => Uint8List.fromList(utf8.encode(s));
static String? _text(Map<String, Uint8List?> txt, String key) {
final v = txt[key];
if (v == null || v.isEmpty) return null;
return utf8.decode(v, allowMalformed: true);
}
}

273
lib/remote/host_server.dart Normal file
View file

@ -0,0 +1,273 @@
// `controller` can't be a private-named parameter, so an initializing formal
// isn't possible for `_controller` (same reason as playback_engine.dart).
// ignore_for_file: prefer_initializing_formals
import 'dart:async';
import 'dart:io';
import '../playback/playback_engine.dart';
import 'command_dispatch.dart';
import 'messages.dart';
/// Turns the local device into a controllable player: runs an in-process
/// WebSocket server, authenticates remotes against the shared server password,
/// streams playback snapshots to them, and applies the commands they send to
/// the local [PlaybackController].
///
/// Lifecycle is owned by `remote_providers.dart`, which starts the host while
/// this device is playable and advertises its [port] over mDNS
/// (`discovery.dart`). The server binds an ephemeral port on all IPv4
/// interfaces; discovery carries the port to remotes.
class RemoteHost {
RemoteHost({
required PlaybackController controller,
required this.serverKey,
required this.password,
required this.deviceName,
}) : _controller = controller;
final PlaybackController _controller;
/// Only remotes reporting this same [serverKey] (i.e. signed into the same
/// Subsonic server) are allowed to connect — song ids must resolve on both.
final String serverKey;
/// Shared secret for the HMAC handshake — never sent, only proven.
final String password;
/// Friendly name shown to remotes ("Living Room iPad").
final String deviceName;
HttpServer? _server;
void Function()? _removeListener;
final Set<WebSocket> _clients = {};
final StreamController<int> _clientCount =
StreamController<int>.broadcast();
/// Monotonic snapshot sequence so a remote can drop stale/out-of-order state.
int _seq = 0;
/// The last state actually put on the wire, for cheap change detection.
PlaybackState? _sentState;
Timer? _throttle;
PlaybackState? _pending;
/// Bound port once [start] has run, else null.
int? get port => _server?.port;
/// Emits the number of connected remotes (drives the "N remotes" UI).
Stream<int> get clientCountStream => _clientCount.stream;
int get clientCount => _clients.length;
/// Bind and begin serving. Returns the chosen ephemeral port. Idempotent —
/// a second call returns the already-bound port.
Future<int> start() async {
final existing = _server;
if (existing != null) return existing.port;
final server = await HttpServer.bind(InternetAddress.anyIPv4, 0);
_server = server;
server.listen((req) async {
if (!WebSocketTransformer.isUpgradeRequest(req)) {
req.response
..statusCode = HttpStatus.forbidden
..close();
return;
}
try {
final ws = await WebSocketTransformer.upgrade(req);
_handleSocket(ws);
} catch (_) {
// Failed upgrade — nothing to clean up.
}
});
// fireImmediately:false — the per-client welcome snapshot covers the
// initial state; we only want deltas here.
_removeListener =
_controller.addListener(_onStateChanged, fireImmediately: false);
return server.port;
}
/// Stop serving and drop every remote. Safe to call when not started.
Future<void> stop() async {
_removeListener?.call();
_removeListener = null;
_throttle?.cancel();
_throttle = null;
_pending = null;
_sentState = null;
for (final c in _clients.toList()) {
await c.close().catchError((_) {});
}
_clients.clear();
if (!_clientCount.isClosed) _clientCount.add(0);
await _server?.close(force: true);
_server = null;
}
Future<void> dispose() async {
await stop();
await _clientCount.close();
}
// ---- Handshake + per-client loop --------------------------------------
void _handleSocket(WebSocket socket) {
final nonce = RemoteAuth.newNonce();
var authed = false;
// Drop a peer that connects but never authenticates.
final authTimeout = Timer(const Duration(seconds: 10), () {
if (!authed) socket.close().catchError((_) {});
});
_send(
socket,
ChallengeMessage(
version: kRemoteProtocolVersion,
serverKey: serverKey,
nonce: nonce,
device: deviceName,
),
);
socket.listen(
(data) async {
RemoteMessage msg;
try {
msg = RemoteMessage.decode(data as String);
} catch (_) {
return; // ignore a malformed frame
}
if (!authed) {
final denyReason = _rejectReason(msg, nonce);
if (denyReason != null) {
_send(socket, DenyMessage(reason: denyReason));
await socket.close().catchError((_) {});
return;
}
authed = true;
authTimeout.cancel();
_send(socket, WelcomeMessage(device: deviceName));
_clients.add(socket);
if (!_clientCount.isClosed) _clientCount.add(_clients.length);
// Immediate full snapshot so the remote paints without waiting for
// the next state change.
_send(socket, SnapshotMessage(_snapshotOf(_controller.currentState)));
return;
}
if (msg is CommandMessage) {
try {
await applyRemoteCommand(_controller, msg.command);
} catch (_) {
// A command that fails locally (e.g. offline host) surfaces via the
// next snapshot's error field — no need to tear down the client.
}
}
},
onDone: () {
authTimeout.cancel();
_removeClient(socket);
},
onError: (_) {
authTimeout.cancel();
_removeClient(socket);
},
cancelOnError: true,
);
}
/// Null if the auth message is acceptable, else a human-readable deny reason.
String? _rejectReason(RemoteMessage msg, String nonce) {
if (msg is! AuthMessage) return 'expected auth';
if (msg.version != kRemoteProtocolVersion) return 'protocol version';
if (msg.serverKey != serverKey) return 'different server';
if (!RemoteAuth.verify(
password: password,
nonce: nonce,
proof: msg.proof,
)) {
return 'authentication failed';
}
return null;
}
void _removeClient(WebSocket socket) {
if (_clients.remove(socket) && !_clientCount.isClosed) {
_clientCount.add(_clients.length);
}
}
// ---- Broadcast --------------------------------------------------------
/// Send discrete changes (play/pause, track, queue, shuffle/loop, error)
/// immediately; coalesce position-only ticks to at most one per second (the
/// remote interpolates position between snapshots).
void _onStateChanged(PlaybackState s) {
if (_clients.isEmpty) {
_sentState = s;
return;
}
final prev = _sentState;
if (prev == null || _discreteChanged(prev, s)) {
_flush(s);
} else {
_pending = s;
_throttle ??= Timer(const Duration(seconds: 1), () {
_throttle = null;
final p = _pending;
_pending = null;
if (p != null) _flush(p);
});
}
}
void _flush(PlaybackState s) {
_throttle?.cancel();
_throttle = null;
_pending = null;
_sentState = s;
final frame = SnapshotMessage(_snapshotOf(s)).encode();
for (final c in _clients.toList()) {
try {
c.add(frame);
} catch (_) {
_removeClient(c);
}
}
}
static bool _discreteChanged(PlaybackState a, PlaybackState b) =>
a.playing != b.playing ||
a.currentIndex != b.currentIndex ||
a.shuffle != b.shuffle ||
a.loop != b.loop ||
a.error != b.error ||
// copyWith reuses the same queue list unless it actually changed, so an
// identity check cheaply distinguishes a queue edit from a position tick.
!identical(a.queue, b.queue);
StateSnapshot _snapshotOf(PlaybackState s) => StateSnapshot(
queue: s.queue,
currentIndex: s.currentIndex,
playing: s.playing,
positionMs: s.position.inMilliseconds,
durationMs: s.effectiveDuration.inMilliseconds,
shuffle: s.shuffle,
loop: s.loop.name,
seq: ++_seq,
);
void _send(WebSocket socket, RemoteMessage msg) {
try {
socket.add(msg.encode());
} catch (_) {
// Socket already gone; the listen callbacks will clean it up.
}
}
}

362
lib/remote/messages.dart Normal file
View file

@ -0,0 +1,362 @@
import 'dart:convert';
import 'dart:math' show Random;
import 'package:crypto/crypto.dart';
import '../subsonic/models.dart';
/// Wire protocol for LAN cross-device control (updates-features.md #3).
///
/// Deliberately dependency-light — only `dart:convert`, `crypto`, and the
/// [Song] model — so the whole protocol is unit-testable without Flutter
/// bindings or a real socket. Higher layers (`host_server.dart` /
/// `remote_session.dart`) own the transport and the just_audio-facing
/// [PlaybackState] <-> [StateSnapshot] conversion; nothing here imports
/// just_audio or riverpod.
///
/// Bump [kRemoteProtocolVersion] on any breaking envelope/field change; both
/// ends refuse to talk across a mismatch (see `host_server.dart`).
const int kRemoteProtocolVersion = 1;
/// mDNS/Bonjour service type advertised + browsed by [discovery]. iOS requires
/// this exact string listed under `NSBonjourServices` in Info.plist.
const String kRemoteServiceType = '_timbre._tcp';
// ---- Auth ---------------------------------------------------------------
/// Challenge-response auth keyed off the shared Subsonic password. Both devices
/// are signed into the same server, so both know the password; proving it
/// (without ever sending it) gates control to the same user without a manual
/// PIN. The host issues a per-connection [newNonce]; the remote returns
/// [proofFor]; the host [verify]s. A fresh nonce per connection stops replay.
class RemoteAuth {
RemoteAuth._();
static final Random _rng = Random.secure();
/// A fresh 128-bit base64url nonce for one connection's challenge.
static String newNonce() {
final bytes = List<int>.generate(16, (_) => _rng.nextInt(256));
return base64Url.encode(bytes);
}
/// `HMAC-SHA256(key = password, msg = nonce)`, hex-encoded.
static String proofFor({required String password, required String nonce}) {
final mac = Hmac(sha256, utf8.encode(password));
return mac.convert(utf8.encode(nonce)).toString();
}
/// Constant-time compare of a received [proof] against the expected one.
static bool verify({
required String password,
required String nonce,
required String proof,
}) {
final expected = proofFor(password: password, nonce: nonce);
if (expected.length != proof.length) return false;
var diff = 0;
for (var i = 0; i < expected.length; i++) {
diff |= expected.codeUnitAt(i) ^ proof.codeUnitAt(i);
}
return diff == 0;
}
}
// ---- Playback snapshot --------------------------------------------------
/// A transport-friendly mirror of the host's `PlaybackState`. `loop` is the
/// enum *name* (just_audio's `LoopMode`) so this file stays free of just_audio;
/// the remote proxy maps it back. [seq] is a monotonic counter so a remote can
/// drop a stale/out-of-order snapshot.
class StateSnapshot {
const StateSnapshot({
required this.queue,
required this.currentIndex,
required this.playing,
required this.positionMs,
required this.durationMs,
required this.shuffle,
required this.loop,
required this.seq,
});
final List<Song> queue;
final int? currentIndex;
final bool playing;
final int positionMs;
final int durationMs;
final bool shuffle;
final String loop;
final int seq;
Map<String, dynamic> toJson() => {
'queue': [for (final s in queue) s.toJson()],
'currentIndex': currentIndex,
'playing': playing,
'positionMs': positionMs,
'durationMs': durationMs,
'shuffle': shuffle,
'loop': loop,
'seq': seq,
};
factory StateSnapshot.fromJson(Map<String, dynamic> j) => StateSnapshot(
queue: (j['queue'] as List? ?? const [])
.whereType<Map>()
.map((e) => Song.fromJson(e.cast<String, dynamic>()))
.toList(),
currentIndex: j['currentIndex'] as int?,
playing: j['playing'] == true,
positionMs: (j['positionMs'] as int?) ?? 0,
durationMs: (j['durationMs'] as int?) ?? 0,
shuffle: j['shuffle'] == true,
loop: (j['loop'] as String?) ?? 'off',
seq: (j['seq'] as int?) ?? 0,
);
}
// ---- Commands -----------------------------------------------------------
/// The remote-control verbs, 1:1 with the `PlaybackCommands` surface the UI
/// drives. `resyncFromPlayer` is intentionally absent — it is a local-engine
/// concern with no cross-device meaning (the remote gets state pushed to it).
enum RemoteOp {
playPause,
next,
previous,
seek,
jumpTo,
playSongs,
playNext,
addToQueue,
removeAt,
reorder,
toggleShuffle,
cycleLoop,
retry,
}
/// One remote command plus whatever payload its [op] needs. A flat bag (rather
/// than a class-per-op hierarchy) keeps the codec and the call sites compact,
/// matching the model style elsewhere in the app. Use the named constructors so
/// each op only carries its own fields.
class RemoteCommand {
const RemoteCommand._(
this.op, {
this.index,
this.oldIndex,
this.newIndex,
this.startIndex,
this.positionMs,
this.songs,
this.song,
});
const RemoteCommand.playPause() : this._(RemoteOp.playPause);
const RemoteCommand.next() : this._(RemoteOp.next);
const RemoteCommand.previous() : this._(RemoteOp.previous);
const RemoteCommand.toggleShuffle() : this._(RemoteOp.toggleShuffle);
const RemoteCommand.cycleLoop() : this._(RemoteOp.cycleLoop);
const RemoteCommand.retry() : this._(RemoteOp.retry);
const RemoteCommand.seek(int positionMs)
: this._(RemoteOp.seek, positionMs: positionMs);
const RemoteCommand.jumpTo(int index) : this._(RemoteOp.jumpTo, index: index);
const RemoteCommand.removeAt(int index)
: this._(RemoteOp.removeAt, index: index);
const RemoteCommand.reorder(int oldIndex, int newIndex)
: this._(RemoteOp.reorder, oldIndex: oldIndex, newIndex: newIndex);
const RemoteCommand.playSongs(List<Song> songs, {int startIndex = 0})
: this._(RemoteOp.playSongs, songs: songs, startIndex: startIndex);
const RemoteCommand.playNext(Song song)
: this._(RemoteOp.playNext, song: song);
const RemoteCommand.addToQueue(Song song)
: this._(RemoteOp.addToQueue, song: song);
final RemoteOp op;
final int? index;
final int? oldIndex;
final int? newIndex;
final int? startIndex;
final int? positionMs;
final List<Song>? songs;
final Song? song;
Map<String, dynamic> toJson() => {
'op': op.name,
if (index != null) 'index': index,
if (oldIndex != null) 'oldIndex': oldIndex,
if (newIndex != null) 'newIndex': newIndex,
if (startIndex != null) 'startIndex': startIndex,
if (positionMs != null) 'positionMs': positionMs,
if (songs != null) 'songs': [for (final s in songs!) s.toJson()],
if (song != null) 'song': song!.toJson(),
};
factory RemoteCommand.fromJson(Map<String, dynamic> j) {
final op = RemoteOp.values.firstWhere(
(o) => o.name == j['op'],
orElse: () => throw const FormatException('unknown remote op'),
);
Song? song;
if (j['song'] is Map) {
song = Song.fromJson((j['song'] as Map).cast<String, dynamic>());
}
return RemoteCommand._(
op,
index: j['index'] as int?,
oldIndex: j['oldIndex'] as int?,
newIndex: j['newIndex'] as int?,
startIndex: j['startIndex'] as int?,
positionMs: j['positionMs'] as int?,
songs: j['songs'] is List
? (j['songs'] as List)
.whereType<Map>()
.map((e) => Song.fromJson(e.cast<String, dynamic>()))
.toList()
: null,
song: song,
);
}
}
// ---- Envelope -----------------------------------------------------------
/// Every frame on the wire is one JSON object tagged by `t`. Handshake order:
/// host → [ChallengeMessage], remote → [AuthMessage], host → [WelcomeMessage]
/// (then a stream of [SnapshotMessage]) or [DenyMessage] + close. After the
/// handshake the remote sends [CommandMessage]s.
sealed class RemoteMessage {
const RemoteMessage();
Map<String, dynamic> toJson();
String encode() => jsonEncode(toJson());
/// Parse a wire frame. Throws [FormatException] on anything unrecognized so
/// the transport can drop a bad/hostile peer rather than mis-handle it.
static RemoteMessage decode(String raw) {
final j = jsonDecode(raw);
if (j is! Map) throw const FormatException('frame is not an object');
final m = j.cast<String, dynamic>();
switch (m['t']) {
case 'challenge':
return ChallengeMessage(
version: (m['v'] as int?) ?? 0,
serverKey: m['serverKey'] as String? ?? '',
nonce: m['nonce'] as String? ?? '',
device: m['device'] as String? ?? '',
);
case 'auth':
return AuthMessage(
version: (m['v'] as int?) ?? 0,
serverKey: m['serverKey'] as String? ?? '',
proof: m['proof'] as String? ?? '',
device: m['device'] as String? ?? '',
);
case 'welcome':
return WelcomeMessage(device: m['device'] as String? ?? '');
case 'deny':
return DenyMessage(reason: m['reason'] as String? ?? '');
case 'state':
return SnapshotMessage(
StateSnapshot.fromJson((m['snapshot'] as Map).cast<String, dynamic>()),
);
case 'cmd':
return CommandMessage(
RemoteCommand.fromJson((m['cmd'] as Map).cast<String, dynamic>()),
);
default:
throw FormatException('unknown message type: ${m['t']}');
}
}
}
/// host → remote: identifies the host and carries the auth nonce.
class ChallengeMessage extends RemoteMessage {
const ChallengeMessage({
required this.version,
required this.serverKey,
required this.nonce,
required this.device,
});
final int version;
final String serverKey;
final String nonce;
final String device;
@override
Map<String, dynamic> toJson() => {
't': 'challenge',
'v': version,
'serverKey': serverKey,
'nonce': nonce,
'device': device,
};
}
/// remote → host: proves knowledge of the shared password and names itself.
class AuthMessage extends RemoteMessage {
const AuthMessage({
required this.version,
required this.serverKey,
required this.proof,
required this.device,
});
final int version;
final String serverKey;
final String proof;
final String device;
@override
Map<String, dynamic> toJson() => {
't': 'auth',
'v': version,
'serverKey': serverKey,
'proof': proof,
'device': device,
};
}
/// host → remote: handshake accepted; snapshots follow.
class WelcomeMessage extends RemoteMessage {
const WelcomeMessage({required this.device});
final String device;
@override
Map<String, dynamic> toJson() => {'t': 'welcome', 'device': device};
}
/// host → remote: handshake rejected (version / server / auth mismatch).
class DenyMessage extends RemoteMessage {
const DenyMessage({required this.reason});
final String reason;
@override
Map<String, dynamic> toJson() => {'t': 'deny', 'reason': reason};
}
/// host → remote: a full playback snapshot.
class SnapshotMessage extends RemoteMessage {
const SnapshotMessage(this.snapshot);
final StateSnapshot snapshot;
@override
Map<String, dynamic> toJson() => {'t': 'state', 'snapshot': snapshot.toJson()};
}
/// remote → host: one control command.
class CommandMessage extends RemoteMessage {
const CommandMessage(this.command);
final RemoteCommand command;
@override
Map<String, dynamic> toJson() => {'t': 'cmd', 'cmd': command.toJson()};
}

View file

@ -0,0 +1,92 @@
import 'package:just_audio/just_audio.dart' show LoopMode;
import '../playback/playback_engine.dart';
import '../subsonic/models.dart';
import 'messages.dart';
import 'remote_session.dart';
/// Map a wire [StateSnapshot] back into the app's [PlaybackState] so the UI can
/// render a remote device's playback exactly as it renders local playback.
PlaybackState playbackStateFromSnapshot(StateSnapshot s) => PlaybackState(
queue: s.queue,
currentIndex: s.currentIndex,
playing: s.playing,
position: Duration(milliseconds: s.positionMs),
duration: Duration(milliseconds: s.durationMs),
shuffle: s.shuffle,
loop: LoopMode.values.firstWhere(
(m) => m.name == s.loop,
orElse: () => LoopMode.off,
),
supported: true,
);
/// The command half of the facade while attached to another device: every
/// [PlaybackCommands] call is serialized into a [RemoteCommand] and sent to the
/// host, which applies it to *its* engine. Playback state comes back the other
/// way as snapshots (see `remote_providers.dart`), so this proxy holds none.
///
/// Commands are fire-and-forget: LAN round-trips are sub-frame, and the host's
/// next snapshot is the source of truth, so there is no optimistic local edit
/// to reconcile.
class RemotePlaybackProxy implements PlaybackCommands {
RemotePlaybackProxy(this._session);
final RemoteSession _session;
@override
Future<void> playSongs(List<Song> songs, {int startIndex = 0}) async =>
_session.send(RemoteCommand.playSongs(songs, startIndex: startIndex));
@override
Future<void> jumpTo(int index) async =>
_session.send(RemoteCommand.jumpTo(index));
@override
Future<void> playNext(Song song) async =>
_session.send(RemoteCommand.playNext(song));
@override
Future<void> addToQueue(Song song) async =>
_session.send(RemoteCommand.addToQueue(song));
@override
Future<void> removeAt(int index) async =>
_session.send(RemoteCommand.removeAt(index));
@override
Future<void> reorderQueue(int oldIndex, int newIndex) async =>
_session.send(RemoteCommand.reorder(oldIndex, newIndex));
@override
Future<void> togglePlayPause() async =>
_session.send(const RemoteCommand.playPause());
@override
Future<void> next() async => _session.send(const RemoteCommand.next());
@override
Future<void> previous() async =>
_session.send(const RemoteCommand.previous());
@override
Future<void> seek(Duration position) async =>
_session.send(RemoteCommand.seek(position.inMilliseconds));
@override
Future<void> toggleShuffle() async =>
_session.send(const RemoteCommand.toggleShuffle());
@override
Future<void> cycleLoop() async =>
_session.send(const RemoteCommand.cycleLoop());
@override
Future<void> retry() async => _session.send(const RemoteCommand.retry());
@override
void resyncFromPlayer() {
// No-op: the remote device owns the player; we receive its state pushed to
// us and have no local playhead to re-read.
}
}

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