import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:bonsoir/bonsoir.dart'; import 'package:flutter/foundation.dart' show kIsWeb; 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; } /// 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. /// /// There are two backends behind this interface, chosen by [RemoteDiscovery]'s /// factory: `nsd` (Bonjour/NSD) on Android/iOS/macOS/Windows, and `bonsoir` /// (Avahi over D-Bus) on Linux — `nsd` has no Linux implementation. Both speak /// the same DNS-SD wire format, so a Linux host and a phone controller /// interoperate. abstract class RemoteDiscovery { factory RemoteDiscovery() { if (!kIsWeb && Platform.isLinux) return _BonsoirDiscovery(); return _NsdDiscovery(); } /// Latest discovered, filtered device list (also replayed as a stream). List get current; Stream> get devices; bool get isAdvertising; bool get isBrowsing; /// Publish this device as a controllable player on [port]. Replaces any /// existing advertisement. Future advertise({ required String deviceName, required int port, required String serverKey, }); Future stopAdvertising(); /// Start browsing for players on [serverKey]'s network. Emits filtered /// [DiscoveredDevice] lists on [devices]. Replaces any existing browse. Future startBrowsing(String serverKey); Future stopBrowsing(); Future dispose(); } /// TXT-record keys shared by both backends' wire format. const _kServerKey = 'sk'; const _kVersion = 'v'; /// Stream plumbing + peer filtering shared by both backends. Keeps the /// same-server / same-version / not-ourselves rules in one place so the two /// implementations only differ in how they talk to their platform. mixin _DiscoveryState { final StreamController> _devices = StreamController>.broadcast(); List _current = const []; /// 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; List get current => _current; Stream> get devices => _devices.stream; bool _accepts(DiscoveredDevice d) { if (_browseServerKey != null && d.serverKey != _browseServerKey) { return false; // different Subsonic server — its song ids won't resolve here } if (d.version != kRemoteProtocolVersion) return false; if (_ownName != null && d.name == _ownName) return false; // ourselves return true; } /// Filter [all] to controllable peers and publish the result. void _emit(Iterable all) { _current = all.where(_accepts).toList(); if (!_devices.isClosed) _devices.add(_current); } Future _closeDevices() => _devices.close(); } /// `nsd`-backed discovery for Android/iOS/macOS/Windows. class _NsdDiscovery with _DiscoveryState implements RemoteDiscovery { Registration? _registration; Discovery? _discovery; ServiceListener? _listener; @override bool get isAdvertising => _registration != null; @override bool get isBrowsing => _discovery != null; // ---- Advertise -------------------------------------------------------- @override 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; } @override 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 ----------------------------------------------------------- @override 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(); } @override 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. } } } @override Future dispose() async { await stopAdvertising(); await stopBrowsing(); await _closeDevices(); } void _rebuild() { final discovery = _discovery; if (discovery == null) return; _emit(discovery.services.map(_toDevice).whereType()); } 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); } } /// `bonsoir`-backed discovery for Linux (Avahi over D-Bus). `nsd` ships no /// Linux implementation, so this backend fills the gap while speaking the same /// DNS-SD service type and TXT keys as [_NsdDiscovery]. /// /// Requires the Avahi daemon to be running; if it isn't, advertise/browse /// simply surface no peers rather than crashing. class _BonsoirDiscovery with _DiscoveryState implements RemoteDiscovery { BonsoirBroadcast? _broadcast; StreamSubscription? _broadcastSub; BonsoirDiscovery? _discovery; StreamSubscription? _discoverySub; /// Resolved peers keyed by mDNS instance name; browsing emits found events /// (no address yet) followed by resolved/updated events that fill in the IP. final Map _found = {}; @override bool get isAdvertising => _broadcast != null; @override bool get isBrowsing => _discovery != null; // ---- Advertise -------------------------------------------------------- @override Future advertise({ required String deviceName, required int port, required String serverKey, }) async { await stopAdvertising(); final service = BonsoirService( name: deviceName, type: kRemoteServiceType, port: port, attributes: { _kServerKey: serverKey, _kVersion: '$kRemoteProtocolVersion', }, ); final broadcast = BonsoirBroadcast(service: service); await broadcast.initialize(); // Avahi renames on conflict; track the name it actually published so the // browser can filter our own advertisement out. _ownName = service.name; _broadcastSub = broadcast.eventStream?.listen((event) { if (event is BonsoirBroadcastStartedEvent) { _ownName = event.service.name; } }); await broadcast.start(); _broadcast = broadcast; } @override Future stopAdvertising() async { await _broadcastSub?.cancel(); _broadcastSub = null; final broadcast = _broadcast; _broadcast = null; _ownName = null; if (broadcast != null) { try { await broadcast.stop(); } catch (_) { // Already gone / Avahi hiccup — nothing to recover. } } } // ---- Browse ----------------------------------------------------------- @override Future startBrowsing(String serverKey) async { await stopBrowsing(); _browseServerKey = serverKey; _found.clear(); final BonsoirDiscovery discovery; try { discovery = BonsoirDiscovery(type: kRemoteServiceType); await discovery.initialize(); } catch (_) { // Avahi unavailable (daemon not running / no D-Bus) — degrade to an // empty device list rather than surfacing an uncaught async error. _browseServerKey = null; return; } _discovery = discovery; _discoverySub = discovery.eventStream?.listen((event) { switch (event) { case BonsoirDiscoveryServiceFoundEvent(): // Found but not yet resolved — ask for its address/TXT records. event.service.resolve(discovery.serviceResolver); case BonsoirDiscoveryServiceResolvedEvent(:final service) || BonsoirDiscoveryServiceUpdatedEvent(:final service): final dev = _toDevice(service); if (dev != null) { _found[service.name] = dev; _emit(_found.values); } case BonsoirDiscoveryServiceLostEvent(): _found.remove(event.service.name); _emit(_found.values); default: break; } }); try { await discovery.start(); } catch (_) { await stopBrowsing(); // tear down the half-started browse cleanly return; } _emit(_found.values); } @override Future stopBrowsing() async { await _discoverySub?.cancel(); _discoverySub = null; _browseServerKey = null; _found.clear(); _current = const []; final discovery = _discovery; _discovery = null; if (discovery != null) { try { await discovery.stop(); } catch (_) { // Already stopped. } } } @override Future dispose() async { await stopAdvertising(); await stopBrowsing(); await _closeDevices(); } DiscoveredDevice? _toDevice(BonsoirService s) { final host = _hostOf(s); if (host == null) return null; // unresolved (no address yet) return DiscoveredDevice( name: s.name, host: host, port: s.port, serverKey: s.attributes[_kServerKey] ?? '', version: int.tryParse(s.attributes[_kVersion] ?? '') ?? 0, ); } /// Prefer a routable IPv4 address; Avahi may also report IPv6 (which contain /// colons) or link-local addresses we can't dial cleanly. static String? _hostOf(BonsoirService s) { final addrs = s.hostAddresses; if (addrs.isEmpty) return null; return addrs.firstWhere((a) => !a.contains(':'), orElse: () => addrs.first); } }