network addition
This commit is contained in:
parent
3bd713d667
commit
2099d3d64d
27 changed files with 2237 additions and 40 deletions
41
lib/remote/command_dispatch.dart
Normal file
41
lib/remote/command_dispatch.dart
Normal 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
200
lib/remote/discovery.dart
Normal 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
273
lib/remote/host_server.dart
Normal 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
362
lib/remote/messages.dart
Normal 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()};
|
||||
}
|
||||
92
lib/remote/remote_playback.dart
Normal file
92
lib/remote/remote_playback.dart
Normal 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.
|
||||
}
|
||||
}
|
||||
135
lib/remote/remote_session.dart
Normal file
135
lib/remote/remote_session.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue