network addition
This commit is contained in:
parent
3bd713d667
commit
2099d3d64d
27 changed files with 2237 additions and 40 deletions
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue