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> _devices = StreamController>.broadcast(); List _current = const []; /// Latest discovered, filtered device list (also replayed as a stream). List get current => _current; Stream> 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 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 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 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 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 dispose() async { await stopAdvertising(); await stopBrowsing(); await _devices.close(); } void _rebuild() { final discovery = _discovery; if (discovery == null) return; final out = []; 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 {}; 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 txt, String key) { final v = txt[key]; if (v == null || v.isEmpty) return null; return utf8.decode(v, allowMalformed: true); } }