scanMdnsDevices function

Future<List<DiscoveredDevice>> scanMdnsDevices({
  1. required String serviceType,
  2. required String mdnsType,
  3. required DiscoveredDevice deviceFactory({
    1. required String ip,
    2. List<String>? mdnsTypes,
    3. required int port,
    4. required String serviceName,
    5. required Map<String, String> txtMap,
    }),
  4. dynamic onDeviceFound(
    1. DiscoveredDevice
    )?,
  5. String? logTag,
  6. Duration sendQueryMessageInterval = const Duration(seconds: 3),
  7. Duration scanDuration = const Duration(seconds: 15),
  8. bool useSystemMdns = false,
})

共用 mDNS 掃描器

Implementation

Future<List<DiscoveredDevice>> scanMdnsDevices({
  required String
  serviceType, // 例如 '_airplay._tcp', '_raop._tcp', '_googlecast._tcp'
  required String
  mdnsType, // 例如 '_airplay._tcp', '_raop._tcp', '_googlecast._tcp'
  required DiscoveredDevice Function({
    required String ip,
    required int port,
    required String serviceName,
    required Map<String, String> txtMap,
    List<String>? mdnsTypes,
  })
  deviceFactory,
  Function(DiscoveredDevice)? onDeviceFound,
  String? logTag,
  Duration sendQueryMessageInterval = const Duration(seconds: 3),
  Duration scanDuration = const Duration(seconds: 15), // 改名為 scanDuration
  bool useSystemMdns = false, // 新增參數,預設 false
}) async {
  final logger = AppLogger();
  final List<DiscoveredDevice> devices = [];
  final deviceMap = <String, DiscoveredDevice>{};
  MDnsClient? client;
  logTag ??= 'mDNS';

  // 平台判斷
  bool isApplePlatform = false;
  try {
    isApplePlatform = Platform.isIOS || Platform.isMacOS;
  } catch (_) {}

  if (useSystemMdns && isApplePlatform) {
    // TODO: 實作系統內建 mDNS 查詢
    await logger.info('info.use_system_mdns', tag: logTag);
    throw UnimplementedError('系統內建 mDNS 尚未實作,請自行擴充');
  }

  try {
    client = MDnsClient(
      rawDatagramSocketFactory: (
        dynamic host,
        int port, {
        bool? reuseAddress,
        bool? reusePort,
        int? ttl,
      }) {
        return _safeBind(host, port, ttl: ttl);
      },
    );
    await client.start();
    await logger.info('info.standard_mdns_port', tag: logTag);
  } on SocketException catch (e) {
    if (e.osError?.errorCode == 48 || e.osError?.errorCode == 10048) {
      await logger.error(
        'errors.port_in_use',
        tag: logTag,
        params: {'port': e.port},
      );
      throw MdnsPortInUseException(
        'mDNS port ￿e.port} is in use, cannot scan for $serviceType devices',
      );
    }
    rethrow;
  } catch (e) {
    await logger.error('errors.mdns_init_error', tag: logTag, error: e);
    rethrow;
  }

  final completer = Completer<void>();
  final List<StreamSubscription> subscriptions = [];
  Timer? periodicQueryTimer;
  bool isCompleted = false;

  Future<void> sendPtrQuery() async {
    final ptrStream = client!.lookup<PtrResourceRecord>(
      ResourceRecordQuery.serverPointer('$serviceType.local'),
    );
    final ptrSubscription = ptrStream.listen((ptr) async {
      final serviceName = ptr.domainName;
      await for (final srv in client!.lookup<SrvResourceRecord>(
        ResourceRecordQuery.service(serviceName),
      )) {
        await logger.debug(
          'debug.found_service',
          tag: logTag,
          params: {
            'target': srv.target,
            'port': srv.port,
            'priority': srv.priority,
            'weight': srv.weight,
          },
        );
        await for (final ip in client.lookup<IPAddressResourceRecord>(
          ResourceRecordQuery.addressIPv4(srv.target),
        )) {
          await for (final txt in client.lookup<TxtResourceRecord>(
            ResourceRecordQuery.text(serviceName),
          )) {
            final txtMap = <String, String>{};
            final dynamic txtRaw = txt.text;
            await _parseTxtRecord(txtRaw, txtMap, logger);

            final device = deviceFactory(
              ip: ip.address.address,
              port: srv.port,
              serviceName: serviceName,
              txtMap: txtMap,
              mdnsTypes: [mdnsType],
            );
            final deviceId =
                '${mdnsType}_${(device.id ?? device.location ?? "${device.ip}_${device.name}")}';
            if (deviceMap.containsKey(deviceId)) {
              await logger.debug(
                'debug.duplicate_device',
                tag: logTag,
                params: {'id': deviceId, 'name': device.name, 'ip': device.ip},
              );
              continue;
            }
            deviceMap[deviceId] = device;
            await logger.info(
              'info.found_device',
              tag: logTag,
              params: {
                'deviceType': device.type.toString(),
                'name': device.name,
                'ip': device.ip,
                'model': device.model,
              },
            );
            if (onDeviceFound != null) {
              onDeviceFound(device);
            }
          }
        }
      }
    });
    subscriptions.add(ptrSubscription);
  }

  try {
    // 首次查詢
    await sendPtrQuery();
    // 週期性查詢
    periodicQueryTimer = Timer.periodic(sendQueryMessageInterval, (timer) {
      if (isCompleted) return;
      sendPtrQuery();
    });

    // Wait for scanDuration to complete (DLNA style)
    Future.delayed(scanDuration, () {
      if (!completer.isCompleted) completer.complete();
    });

    try {
      await completer.future;
    } catch (e) {
      rethrow;
    }
    isCompleted = true;
    periodicQueryTimer.cancel();
    for (final sub in subscriptions) {
      await sub.cancel();
    }
  } catch (e) {
    if (e is SocketException && e.osError?.errorCode == 1) {
      await logger.error(
        'errors.mdns_send_permission_denied',
        tag: logTag,
        params: {'error': e.toString()},
      );
      throw Exception(
        'mDNS permission denied: unable to send multicast packets. Please ensure you have network and multicast permissions, or run as root/administrator.',
      );
    }
    await logger.error('errors.scan_failed', tag: logTag, error: e);
  } finally {
    bool clientStopped = false;
    if (!clientStopped) {
      try {
        client.stop();
      } catch (_) {}
      clientStopped = true;
    }
    periodicQueryTimer?.cancel();
    for (final sub in subscriptions) {
      await sub.cancel();
    }
  }
  devices.addAll(deviceMap.values);
  await logger.info(
    'info.scan_complete',
    tag: logTag,
    params: {'count': devices.length},
  );
  return devices;
}